|
||
---|---|---|
bin | ||
build-templates | ||
examples | ||
images | ||
src | ||
tests | ||
web | ||
.gitignore | ||
.nvmrc | ||
.unimportedrc.json | ||
.woodpecker.yml | ||
CONTRIBUTORS.md | ||
LICENSE | ||
README.md | ||
build | ||
contributors.js | ||
deploy | ||
featured-contributors.md | ||
install | ||
kitten-day.sixel | ||
package | ||
package-lock.json | ||
package.json | ||
suppress-experimental.cjs | ||
update-version.js |
README.md
🐱️ Kitten
A 💕 Small Web development kit.
Kitten is a little kit (“kitten”, get it?) optimised for people (not corporations) who make the new everyday things for themselves and other people – not because they want to become billionaires but because they want to contribute to the common good by helping nurture the Small Web while adhering to the Small Technology Principles.
- Build on a solid core of plain HTML, CSS, and JavaScript.
- Progressively enhance using htmx to create modern Hypermedia-Driven Applications (HDA).
- Sprinkle on yet more interactive delight using Alpine.js.
- Go beyond the client-server web to build peer-to-peer Small Web applications that support end-to-end encrypted communication.
- Build process? What build process?
Meow!
Kitten features intuitive file system-based routing, HTML and accessibility validation, a built-in object database, WebSockets, zero-code authentication, public-key cryptography, Markdown support, syntax highlighting via highlight.js, semantic styles via Water CSS, and more.
🍼 Warning: Kitten’s still a baby.
Kitten is neither feature complete nor ready for production use.
Sections marked 🚧 represent unimplemented features.
Please feel free to have a play but proceed at your own risk. Here be
dragonskittens.
System requirements
- Linux, macOS, and Windows (WSL 2).
- Bash version 5.x+.
- Common developer tools and system utilities (
git
,tar
,tee
, andxz
).
💡 macOS comes with an ancient version of Bash. To upgrade it to a modern one, run
brew install bash
using Homebrew.💡For production servers, only Linux with systemd is supported.
Getting started
Install Kitten
Linux and macOS
Either:
😇 Clone this repository and run the ./install
script, or
😈 Copy and paste one of the following commands into your terminal to pipe the installation script into your shell and run it:
🤓 You can download and view the install script source to verify it’s safe to pipe to your shell: https://kittens.small-web.org/install. And also compare it to: https://codeberg.org/kitten/app/raw/branch/main/install
Using wget:
wget -qO- https://kittens.small-web.org/install | bash
Using curl:
curl -sL https://kittens.small-web.org/install | bash
💡If you’re running an “immutable” Linux distribution like Fedora Silverblue, please install Kitten from your host account (instead of from within a container) at least once so Kitten can set unprivileged ports to start from 80 (so it can run without elevated privileges).
💡If you want to install not the latest version of Kitten but the latest version for a given API version, modify your download command to tack the api version to the URL and to pass it as an argument to the bash script. For example, if you wanted to install the latest Kitten package for API version 1 using CURL, your command would look like this:
curl -sL https://kittens.small-web.org/install/1/ | bash -s -- 1
Windows
Kitten runs on Windows 10 and 11 under WSL 2.
The installation process, however, is not as seamless as it is on Linux and macOS. You must first install WSL 2 and you must manually add Kitten’s local development-time certificate authority to the trust stores of your Windows browsers to avoid certificate errors.
For step-by-step instructions, please see:
Install Kitten on Windows under WSL 2.
Like this? Fund us!
Small Technology Foundation is a tiny, independent not-for-profit.
We exist in part thanks to patronage by people like you. If you share our vision and want to support our work, please become a patron or donate to us today and help us continue to exist.
Tutorials
Hello, world!
Let’s quickly create and test your first “Hello, world!” Kitten site.
-
Create a directory for the example and enter it:
mkdir kitten-playground cd kitten-playground
-
Create a file called index.html and add the following content to it:
<h1>Kitten</h1> <p>🐱️</p>
-
Run Kitten using the following syntax:
kitten
Once Kitten is running, hit https://localhost, and you should see your new site.
💡 When you run
kitten
without any arguments, it defaults to serving the current directory. You can also specify a path as the first argument (kitten [path to serve]
) to serve a different directory than the one you’re currently in.💡 Notice that you didn’t get a certificate error. This is because Kitten automatically creates a development-time TLS certificate for your local machine signed by a local certificate authority that it adds to your system’s trust store (and to Firefox, if you have it installed) using Auto Encrypt Localhost.
(If you’re running on Windows under WSL 2 and you do get a certificate error, please remember to manually install the local development certificates in your browsers.)
💡 You’re not limited to accessing your local machine via https://localhost. You can also use any local IP address that it’s accessible from (see Accessing your local machine from other devices on your local area network for more information). You can also use the IP aliases 127.0.0.2 - 127.0.0.4 and localhost aliases (subdomains) place1.localhost - place4.localhost without certificate errors. The latter, especially, are useful when testing the peer-to-peer features of Small Web apps.
💡 Small Web places made with Kitten are meant to be owned and used by one person. Until and identity and secret has been generated for this person, Kitten will display a link to the special page that will generate them. (You secret on a Small Web place never hits the server.)
💡 By default, Kitten will be as quiet as possible and only surface warnings and errors. If you want more extensive logging, start it with the VERBOSE environment variable set:
VERBOSE=true kitten [path to serve]
Similarly, if you want to see performance statistics, set
PROFILE=true
.Note that the first time Kitten is run, it will create a TLS certificate authority and add it to your system trust stores as well as to Firefox and Chrome so you can run your development environment from https://localhost to match the deployment environment as closely as possible. Your operating system will ask your permission before allowing this.
So we’ve seen how Kitten happily serves any HTML you throw at it just like any good web server should.
But you can render HTML using any old web server…
Let’s do something no other web server can do, shall we?
Counting kittens.
You know what’s better than one kitten? Many kittens!
Rename your index.html file to index.page.js and update its contents to match the following:
let count = 1
export default () => kitten.html`
<h1>Kitten count</h1>
<p>${'🐱️'.repeat(count++)}</p>
`
Now run kitten
, go to 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 (and hence the page.js extension).
Kitten uses file system routing. So, in this example, your /index.page.js file is mapped to the root route (/) of your site.
💡 Since Kitten pages – and components, etc., as you’ll see later – are just JavaScript files, you don’t need any extra tooling to make things with Kitten. Your editor of choice will likely already know how to work with JavaScript (and HTML and CSS) files or can be easily configured to do so.
A route in Kitten is just an exported default function inside of a JavaScript module. Any HTML you return from your route is sent to the browser.
You write your HTML inside of
kitten.html
tagged template literals. These are just standard JavaScript template literals (template strings) that get passed through a special global function calledkitten.html
.
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. In the version below, you can explicitly see everything that’s happening (to underscore – no pun intended – that there’s really no magic involved).
let count = 1
export default function route (_request, _response) {
return kitten.html`
<h1>Kitten count</h1>
<p>${'🐱️'.repeat(count++)}</p>
`
}
With the above version of the code, it’s clear that what you’re exporting from the .page.js file is a route and that it gets passed the HTTP request and response objects.
💡️ 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 intent when coding.
Demystifying things
Take a look at the title of the tab when you run the example. Do you see that it reads “Kitten count”?
That’s odd, you didn’t tell Kitten to set the title of the page but if you look at the source code of the page, you’ll see that it’s set in the <head>
of your page as:
<title>Kitten count</title>
How’d that happen?
Well, Kitten tries to do the right thing by default. So, if you don’t set a title for your page, it tries to derive one for you by looking to see if there is a <h1>
tag on your page and using that if there is. It might not be perfect but it’s better than nothing if all you’re doing is coding up a quick example.
But what if you want to set your own title?
Enter, the page tag.
The <page>
tag
Kitten has a special tag called <page>
that you can use to set commonly found elements in the heads of your pages (lang
, title
, charset
, icon
, and viewport
) as well as to include the libraries that Kitten has first-class support for (HTMX, HTMX WebSocket, Alpine.js, and Water.css).
To use it, you simple include the tag anywhere in a page (or fragment or component):
let count = 1
export default () => kitten.html`
<page title='Kitten count example'>
<h1>Kitten count</h1>
<p>${'🐱️'.repeat(count++)}</p>
`
If you run the example now, you’ll see that the title you supplied is shown instead of the default one derived from the <h1>
element.
💡 In addition to the
<html>
tag’slang
attribute, the regular<head>
settings, and supported libraries, you can also use thesyntax-highlighting-theme
attribute to specify the highlight.js theme to be applied to code blocks rendered from<markdown>…</markdown>
sections in your code.
💡 You’ll see later that you can set anything you want in the head of your pages using the special
HEAD
slot (<content for='HEAD'>…</content>
) but the<page>
tag should let you set the most common things you need when making Small Web sites and apps with Kitten.
A little less magic, a little more type
A design goal of Kitten is to be easy to play with. Want to spin up a quick experiment or teach someone the basics of web development? Kitten should make that simple to do. Having magic globals like the kitten.html
tagged template you saw earlier help with that.
However, for larger or longer-term projects where maintainability becomes an important consideration, you might want to implement type checking. Contrary to popular belief, you don’t have to use TypeScript or have a build process for this.
You can simply document your types using JSDoc and turn on type checking by adding a // @ts-check
comment to the top of your JavaScript files and any modern development environment that supports language intelligence for JavaScript via the TypeScript Language Server (e.g., Helix Editor, VSCodium, etc.) will pick them up and use them to provide you with static type checking, auto-completion, etc.
The problem is, what do you do about Kitten’s magic globals?
(If you have type checking on, you will get errors like Cannot find name 'html'
if you use them.)
The answer is, you can install and use the type-safe Kitten namespace package (@small-web/kitten
) in your project.
Here’s how you’d update the Kitten Count example to do this:
-
Create a miminal package.json file:
{ "name": "kitten-count-typed", "type": "module" }
-
Install the package:
npm install @small-web/kitten
-
Import the strongly-typed kitten object (it’s the default export):
// @ts-check import kitten from '@small-web/kitten' let /** number */ count = 1 export default () => kitten.html` <h1>Kitten count</h1> <p>${'🐱️'.repeat(count++)}</p> `
All the objects you find under the kitten
namespace are also exported separately so, if you want to, you can also import just the objects you want. The following listing is thus equivalent to the one above:
// @ts-check
import { html } from '@small-web/kitten'
let /** number */ count = 1
export default () => html`
<h1>Kitten count</h1>
<p>${'🐱️'.repeat(count++)}</p>
`
💡️ You can find the source code for this and other examples in Kitten’s source code repository, under the examples/ folder. Source code links are given below for your reference. Keep reading here for tutorials that take you through creating each one of them in turn:
- Kitten Count: examples/kitten-count
- Kitten Count (Typed): examples/kitten-count-typed
- Persisted Kitten Count (JSDB): examples/persisted-kitten-count
- Kitten Chat (WebSocket): examples/kitten-chat
- Persisted Kitten Chat (WebSocket, JSDB): examples/persisted-kitten-chat
- End-to-end encrypted Kitten Chat (WebSocket, JSDB, Kitten Cryptography API): _examples/end-to-end-encrypted-kitten-chat
- Fetchiverse (fetch API): examples/fetchiverse
- Streaming Fediverse Posts (fetch API, WebSocket, htmx, Alpine.js): examples/streamiverse
- Guestbook (simplest possible POST/redirect/GET example): examples/guestbook
- Trivia (node modules, POST/redirect/GET, etc.): examples/trivia
💡️ If a project contains node module dependencies, Kitten will automatically call
npm install
for you before serving it.
HTML is so 1991 (introducing Markdown)
HTML is great but did you know you can also use Markdown? (Well, now you do.)
Here’s how the Kitten Count example looks in Markdown.
let count = 1
export default () => kitten.markdown`
# Kitten count
${'🐱️'.repeat(count++)}
`
Pretty neat!
(Granted, it’s not a great use of Markdown but you get the idea.)
Kitten’s Markdown support comes from markdown-it.
Kitten supports a very rich set of Markdown features including automatic anchors for headings, syntax highlighting, figure (and figure caption) support, footnotes, typographical niceties (like converting typewriter quotes "" into curly quotes “”, insert, mark, subscript, superscript, etc.) and even table of contents creation for Markdown sections.
We’ll look at Markdown in more depth in its own example later. For now, know that you can add Markdown to your pages by using kitten.markdown
tagged template strings and/or by enclosing Markdown regions within your HTML between <markdown>…</markdown>
tags.
🚧 The Markdown example is yet to be written but you can see almost all the features demonstrated in examples/components and examples/markdown for the time being.
💡 Unlike HTML, Markdown is whitespace-sensitive so make sure you indent your code properly or it will get parsed incorrectly.
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 (kitten.db.kittens === undefined) kitten.db.kittens = { count: 1 }
export default () => kitten.html`
<h1>Kitten count<h1>
<p>${'🐱️'.repeat(kitten.db.kittens.count++)}</p>
`
Your page should automatically reload in the browser with the new count.
Now refresh the page a few times, manually 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 following folder:
~/.local/share/small-tech.org/kitten/databases
You should see another folder in it that mirrors the path of your project along with the domain and port you ran it at.
For example, if your project is in:
/var/home/aral/kitten-playground
And you ran it locally at the default domain and port (which is localhost
and 443
), then the folder will be named:
var.home.aral.kitten-playground.localhost.443
Inside this folder (your project’s database folder), you should see a kittens.js file.
Open it up in a text editor and take a look at it.
It should look something like this:
export const _ = { 'count': 40 };
_['count'] = 41;
_['count'] = 42;
That’s what a table looks like in JavaScript Database (JSDB), the database that’s integrated into Kitten and available from all your routes via the global db
reference.
Kitten db command
Kitten provides you with a simple way to get information about and see a live view of your tables.
To get general information about the database, including where the database is located and which tables it has, use the db
command from your project’s folder:
kitten db
If you want to see a live view of the contents of a table, add its name to the db
command.
So for the Persisted Kitten Count example, if you wanted to see the kittens table, you would enter:
kitten db kittens
💡 You are not limited to storing plain objects in your database. You can also store custom objects (instances of custom classes) and get them back with the correct type when you read them. That’s a more advanced feature, however, and you will need to implement a database app module to do it.
Check out the database app module in the Domain project to see an example of advanced database use in a larger real-world app.
💡 There’s so much more to JSDB and you can learn all about in the JSDB documentation.
Initialising things
If initialising your database table in your route feels a little yucky, that’s because it is. What if more than one route needed to use that table? If we weren’t absolutely certain that the routes would be called in a given order, we’d have to repeat the conditional initialisation in every route just to be safe.
You can see how this could become a maintenance nightmare.
Fear not, Kitten to the rescue!
If you create a special script called main.script.js in the root of your project folder and export a default function from it, Kitten will import that function and run it at start up.
This is a great place to carry out global initialisation for your app. This function also gets passed a reference to the Polka app instance so you can perform advanced tasks like adding custom middleware, etc., if you need to.
For our purposes, we could move the conditional initialisation of our database table to main.script.js like this:
export default function (_app) {
if (kitten.db.kittens === undefined) kitten.db.kittens = { count: 1 }
}
Which would leave our route looking rather pristine:
export default () => kitten.html`
<h1>Kitten count<h1>
<p>${'🐱️'.repeat(kitten.db.kittens.count++)}</p>
`
💡 There is another way to achieve this that’s specifically designed for the purpose of initialising databases (and adding strong typing to them) using a special App Module. You haven’t seen App Modules yet but, to get a feel for it, take a look at how it’s used in Domain: https://codeberg.org/domain/app/src/branch/main/app_modules/database
How many kittens are too many kittens?
So kittens are great but maybe after a certain number we should truncate the kitten emojis and display the exact count to save the person from having to count them, as fun as that might be.
Enter, conditionals.
Conditionals
Let’s add a conditional so once there are more than twenty kittens, we show a message that states the additional number instead of showing yet more kitten emojis:
if (kitten.db.kittens === undefined) kitten.db.kittens = { count: 1 }
export default () => {
kitten.db.kittens.count++
return kitten.html`
<h1>Kitten count</h1>
<p>${'🐱️'.repeat(kitten.db.kittens.count > 20 ? 20 : kitten.db.kittens.count)}</p>
<if ${kitten.db.kittens.count > 20}>
<p>(and ${kitten.db.kittens.count - 20} more.)</p>
</if>
`
}
Ooh, what’s that? An <if>
tag? That’s not HTML.
No, it’s not. It’s a little extension to HTML that’s unique to Kitten.
What you see above is actually a convenient shorthand form of the <if>
tag that comes with an important limitation: it can only be used to display one tag.
In the above example, that means that we can only include that one <p>
tag after the <if>
. The <p>
tag itself can have any number of children but you cannot add any sibling tags.
To understand why, let’s look at the full syntax if the <if>
tag:
kitten.html`
<if ${condition}>
<then>
<h1>The condition is true :)</h1>
<img src='woot.gif' alt='Geeks partying'>
<p>And there was much rejoicing!</p>
</then>
<else>
<h1>The condition is false :(</h1>
<p>Sad trombone.</p>
</else>
</if>
`
The <then>
and <else>
tags, apart from being optional (as you saw in the shorthand version, where they’re omitted), are also optionally-closed.
So the above example can also be written as shown below without losing the ability to include any number of siblings:
<if ${condition}>
<then>
<h1>The condition is true :)</h1>
<img src='woot.gif' alt='Geeks partying'>
<p>And there was much rejoicing!</p>
<else>
<h1>The condition is false :(</h1>
<p>Sad trombone.</p>
</if>
In fact, most times, this last form is the one you should favour unless you only have a tiny snippet of code (with a single root node) that you want to add conditionally to your page, in which case you can use the first form you saw in the initial example.
The one downside of this canonical form is that you’re left with a dangling indent right before the closing tag (</if>
). If that gets on your nerves or makes it more difficult to ensure that your markup is valid at a glance, please feel free to use the full form. It really is up to you.
💡 Since the
<then>
and<else>
tags are optional, the way the parser works is that it takes the first child of the<if>
tag as thetrue
condition and the second child as thefalse
condition.
🪤 The
<if>
conditional does not short circuit so if you have values in your branches that might be null or undefined, use conditional chaining to ensure you don’t encounter runtime errors. Either that, or use JavaScript conditionals in your templates instead.
Different kittens for different folks
We’ve just seen how we can keep a persisted count of kittens.
The count, however, will be the same at any given time for anyone who visits the site.
So, if I visit the site from my computer and see 10 kittens and you visit the site right afterwards from your phone, you’ll see 11 kittens (because my visit raised the count by one).
This is because we’re persisting a single kitten count.
But what if we wanted every person who visits the site to have their own kitten count, as special and unique as they are?
Enter, sessions.
Sessions
Kitten has built-in session support to make implementing sessions trivial in your sites.
Since the protocol of the web (HTTP) is stateless, we need to use a database if we want to persist information. But what if we want to persist different information for each person that visits our site?
Or, more precisely, for each browser that visits our site.
Clearly, we need a way for our server to know that the request that just came in belongs to the same browser that sent that other request a few minutes ago. While there are different ways to do this, the method that Kitten uses is based on cookies.
💡Wait a minute… aren’t cookies evil things that Big Tech uses to violate your privacy?
Yes, and no.
You see, there are two types of cookies: first-party and third-party. The latter are what’s used to track you. The former enable sites to implement basic functionality like sessions.
To make it easier to remember, think of first-party cookies as the ones your mom bakes for your birthday and third-party cookies as the ones a wicked witch makes to lure little kids into her hut.
The great thing about having built-in support for sessions is that you don’t have to worry about any of this. Whenever a request comes into one of your routes, you can simply access the session object for it using:
request.session
So what it that object?
It’s simply an object that’s persisted in your site’s database.
You can see the session objects that are generated by your site by viewing the sessions table in your database. For example, on my machine, I can view the sessions table for the project my in kitten-playground by running:
tail --follow ~/.local/share/small-tech.org/kitten/databases/var.home.aral.kitten-playground.localhost.443/sessions.js
(Look at Kitten’s console output to get the path for your machine/operating system.)
Kitten Count (with sessions)
So let‘s update our original persisted Kitten example to keep a separate count of Kittens for each browser that accesses it:
export default function (request, _response) {
if (!request.session.kittens) {
request.session.kittens = { count: 1 }
}
return kitten.html`
<h1>Kitten count</h1>
<p>${'🐱️'.repeat(request.session.kittens.count++)}</p>
`
}
Now run the example using the kitten
command and hit it from different browsers. You should see that each has a different count of kittens.
To see the cookie that Kitten sets, open up the Storage tab of your browser’s developer tools and look under the Cookies section.
Delete the cookie and see what happens.
You can find this example at examples/kitten-count-sessions. There is also a more complicated session-based example at examples/trivia that uses the session to store a person’s game state.
Database App Module
💡 This is an advanced feature. Please feel free to skip this section and return to it later if you find it confusing.
You are not limited to using the default, untyped database Kitten sets up for you.
The default database, like many of the beautiful defaults in Kitten, exists to make it easy to get started with Kitten and use it to build quick Small Web places, teaching programming, etc.
If, however, you’re designing a Small Web place that you will need to maintain for a long period of time and/or that you are going to collaborate with others on, it would make sense to create your own database and to make it type safe so you get both errors and code completion during development time.
You can create your own custom database using a special type of app module called the database app module.
💡 Learn more about App Modules.
Like any other app module, your database app module goes in the special app_modules directory in the root of your project. What’s special is that the app module must be called database.
Start a new project and create your database app module by adding the following files in the app_modules/database/ folder:
package.json
{
"name": "@app/database",
"type": "module",
"main": "database.js",
"dependencies": {
"@small-tech/jsdb": "^4.0.0",
"@small-web/kitten": "^2.1.0"
}
}
Since app modules are local node modules, they must all contain a package.json file. Here, we’re declaring the JavaScript Database (JSDB) and the @small-web/kitten library (which contains a strongly-typed kitten
global instead of the untyped default one) are our two dependencies.
JSDB is the database Kitten uses for databases and Kitten expects your database app modules to use it also. No other databases are supported.
Kitten automatically installs the dependencies of app modules and checks for updated dependencies based on the package-lock.json file and installs them if necessary. As long as you make proper use of the package-lock.json file in your Small Web projects, Kitten should keep them updated for you during both development and production.
Next, let’s create the database.js file that we’ve denoted as the main
entry point into our module:
database.js
//@ts-check
import path from 'node:path'
import JSDB from '@small-tech/jsdb'
export class Kitten {
name = ''
age = 0
constructor (name, age) {
this.name = name
this.age = age
}
toString () {
return `${this.name} (${this.age} year${this.age === 1 ? '' : 's'} old)`
}
}
/**
@typedef {object} DatabaseSchema
@property {Database} database
@property {Array<Kitten>} kittens
*/
class Database {
initialised = false
}
// When the database is being opened by the db commands, we don’t compact it.
const compactOnLoad = process.env.jsdbCompactOnLoad === 'false' ? false : true
export const db = /** @type {DatabaseSchema} */ (
JSDB.open(
path.join(globalThis.kitten.databaseDirectory, 'db'),
{
compactOnLoad,
classes: [
Kitten
]
}
)
)
export async function initialise () {
if (!db.database) {
db.database = new Database()
}
if (!db.database.initialised) {
db.kittens = [
new Kitten('Fluffy', 1),
new Kitten('Ms. Meow', 3),
new Kitten('Whiskers', 7)
]
db.database.initialised = true
console.info(`\n • Database initialised.`)
}
return db
}
export default db
The most important thing to note is the async
initialise
function that we’re exporting from the module.
Kitten will import and run this before starting the server when a database app module is found in your project. Furthermore, it will take the database reference returned from this function as set it as the default database (globalThis.kitten.db
or, simply, kitten.db
).
Notice a few other things:
We are using @ts-check
to have our editor use the TypeScript language server to check for type errors and everything, including the database schema, is strongly typed.
This makes authoring easier as you will get type completions as well as errors when working with database objects.
Also, note that we set JSDB’s compactOnLoad
option based on the value of the process.env.jsdbCompactOnLoad
environment variable set by Kitten. This is to enable Kitten’s database commands to run without compacting (and thereby altering) your database tables while your app might be running (e.g., when you’re debugging your app by running the kitten db tail
command in a separate Terminal window/pane.)
Finally, note that we are storing instances of the Kitten
class in the database and, when opening the JSDB database, we are specifying the Kitten
class in the classes
property of the options object that we pass as the second argument.
Next, in order for our types to be recognised when the module is imported, we need to add a very simple TypeScript type definition file:
index.d.ts
// Export database instance as default export.
import db from './database.js'
export default db
// Also export all other exports.
export * from './database.js'
The database is ready so now comes the fun part: using it.
Let’s add a page to display the kittens in the Kitten database, along with their ages:
index.page.js
// @ts-check
import _ from '@small-web/kitten'
/** @type {import('./app_modules/database/database.js').DatabaseSchema} */
const db = _.db
export default () => _.html`
<h1>Kittens</h1>
<ul>
${db.kittens.map(kitten => _.html`
<li>${kitten}</li>
`)}
</ul>
`
💡 An app about kittens is an edge case so we import the kitten global as
_
(underscore) to avoid confusing the library with actual (well, virtual) kittens.
The great advantage of using database app modules is that your database, like the rest of your project, can be strongly typed.
View the type of the kitten variable in the html template, for example, to confirm that it is, indeed, a Kitten
instance.
Also note that because the kittens are instances of the Kitten
class, we can simply refer to them in the template and their description will be printed out for us as their overriden toString()
methods are called behind the scenes.
Fetchiverse
Since Kitten uses Node.js as its runtime, you can install, import, and use Node modules in your project just like in any other Node.js project.
💡 Kitten installs the latest long-term support (LTS) version of Node.js as its runtime.
You don’t need to install Node or npm yourself to make sites and apps with Kitten.
If you need to use npm, Kitten provides a handy alias to its version of npm with the
kitten-npm
command. Similarly, you can access Kitten’s version of node using thekitten-node
command.Note that Kitten is now using Node 20 even though it’s not LTS yet to avoid a performance regression introduced in Node 18 that affects OCSP stapling.
That said, 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.
Welcome to the fediverse
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.js in it.
Now 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()
return kitten.html`
<h1>Aral’s Public Fediverse Timeline</h1>
<ul>
${posts.map(post => (
kitten.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'>
${kitten.safelyAddHtml(post.content)}
${post.media_attachments.map(media => (
media.type === 'image' ? kitten.html`<img class='image' src='${media.url}' alt='${media.description}'>` : ''
))}
</div>
</li>
`
))}
</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; }
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%; }
</style>
`
}
Run Kitten and hit https://localhost to see the latest public timeline from Aral’s mastodon instance.
🔒 Notice the call to
kitten.safelyAddHtml()
when rendering the post’s content.This is content that we don’t necessarily trust so we have to be careful with how we add it to our page.
(OK, in this case, we know we’re using HTTPS for the connection to Aral’s Mastodon server and we can be reasonably sure that Aral, his host (toot.io *waves at Jan*), and Mastodon’s source code are trustworthy but still… who knows, maybe Aral got hacked and his instance is sending carefully-crafted
<script>
tags in the content to exfiltrate your secrets.)To see what happens if we forget to add this global function call, remove it and run the example again. Ah, you see the escaped content. So Kitten tries to be as safe by possible by default and will escape any strings you attempt to interpolate into your page. However, in this case, we do want to display HTML. We just want to display a subset of safe HTML.
For content like this, Kitten provides the convenient global
kitten.safelyAddHtml()
method that uses the sanitize-html module.
💡 In this example you saw how to set attributes on HTML tags for the first time.
Here, we’re only setting string values and they work as you would expect. However, there are also HTML boolean attributes that work differently in HTML than, well, pretty much anywhere else:
If a boolean HTML attribute is present, it is “true”. If it is absent, it is “false.”
An example of such a boolean HTML attribute is
disabled
.To make matters even more confusing, you cannot set an HTML boolean attribute to
false
. If you do, e.g., if you setdisabled=false
, your element will end up disabled.To specify an HTML boolean attribute in Kitten, you can use the following idiom:
let disabled = true kitten.html` <button ${disabled && 'disabled'}>Press me!</button> `
The above code outputs
<button disabled>…</button>
.
💾 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 projects, it would help us to 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 postsResponse.json()
return kitten.html`
<h1>Aral’s Public Fediverse Timeline</h1>
<ul>
${posts.map(post => kitten.html`
<li>
<${Avatar} post=${post} />
<${Content} post=${post} />
</li>
`)}
</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; }
li {
display: flex; align-items: flex-start; column-gap: 1em; padding: 1em;
margin-bottom: 1em; background-color: #ccc; border-radius: 1em;
}
</style>
`
}
const Avatar = kitten.html`
<a class='Avatar' href='${post.account.url}'>
<img src='${post.account.avatar}' alt='${post.account.username}’s avatar' />
</a>
<style>
.Avatar img {
width: 8em;
border-radius: 1em;
}
</style>
`
const Content = ({ post }) => kitten.html`
<div class='Content'>
${kitten.safelyAddHtml(post.content)}
${post.media_attachments.map(media => (
media.type === 'image' && kitten.html`<img src='${media.url}' alt='${media.description}'>`
))}
</div>
<style>
.Content { flex: 1; }
.Content p:first-of-type { margin-top: 0; }
.Content p { line-height: 1.5; }
.Content a:not(.Avatar) {
text-decoration: none; background-color: rgb(139, 218, 255);
border-radius: 0.25em; padding: 0.25em; color: black;
}
.Content img { max-width: 100%; }
/* Make sure posts don’t overflow their containers. */
.Content a {
word-break: break-all;
}
</style>
`
Notice how we split up the styles also and encapsulated them in the components that they pertain to, scoping them to the component itself using class prefixes.
You might be wondering what happens when more than one copy of a component is included on the page. Do the style tags get replicated? The short answer is no. Kitten is smart enough to deduplicate the style tags in your components before rendering the page. In fact, it gathers all the styles on your page into a single, neat <style>
tag in the <head>
of your page.
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 them 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.js and .fragment.js files, respectively.
💡 A good rule of thumb is that if an element will be reused in multiple places on a page and/or in multiple pages, it’s a component. If it is a part of page that might be rendered separately from it (or makes sense to separate for organisational reasons to make maintenance easier), it’s a fragment.
💡 If you’re using a component for layout, you can also use a .layout.js extension. Like every other Kitten-specific extension, these are just JavaScript files but Kitten knows that these are server-side routes and should not be served as client-side JavaScript.
So here’s one way we could organise the code:
index.page.js
import Avatar from './Avatar.component.js'
import Content from './Content.component.js'
export default async function route (_request, _response) {
const postsResponse = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
const posts = await postsResponse.json()
return kitten.html`
<h1>Aral’s Public Fediverse Timeline</h1>
<ul>
${posts.map(post => (
kitten.html`
<li>
<${Avatar} post=${post} />
<${Content} post=${post} />
</li>
`
))}
</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; }
li {
display: flex; align-items: flex-start; column-gap: 1em; padding: 1em;
margin-bottom: 1em; background-color: #ccc; border-radius: 1em;
}
</style>
`
}
Avatar.component.js
export default ({ post }) => kitten.html`
<a class='Avatar' href='${post.account.url}'>
<img src='${post.account.avatar}' alt='${post.account.username}’s avatar' />
</a>
<style>
.Avatar img {
width: 8em;
border-radius: 1em;
}
</style>
`
Content.component.js
export default ({ post }) => kitten.html`
<div class='Content'>
${kitten.safelyAddHtml(post.content)}
${post.media_attachments.map(media => (
media.type === 'image' && kitten.html`<img src='${media.url}' alt='${media.description}'>`
))}
</div>
<style>
.Content { flex: 1; }
.Content p:first-of-type { margin-top: 0; }
.Content p { line-height: 1.5; }
.Content a:not(.Avatar) {
text-decoration: none; background-color: rgb(139, 218, 255);
border-radius: 0.25em; padding: 0.25em; color: black;
}
.Content img { max-width: 100%; }
/* Make sure posts don’t overflow their containers. */
.Content a {
word-break: break-all;
}
</style>
`
💡 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.
Locality of behaviour is about keeping related functionality together so you can easily read through the code in a linear fashion.
In this example, I probably would have kept everything in a single file since it’s so little code and since I don’t need to include the Avatar or Content components from any other routes. 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.
Component idiom
The component idiom in Kitten is for your components to have a single root element and for that element to have a class name that’s used to scope classes to it.
🪤 You can have a component with multiple root elements but this will cause issues if you return just that component in an Ajax request to htmx. If you absolutely want to do this (not recommended), you must flatten the array returned by the component before sending it over the wire or else you’ll get the following error:
🞫 Error ERR_INVALID_ARG_TYPE: The "chunk" argument must be of type string or an instance of Buffer or Uint8Array. Received an instance of Array.
HTML attributes
In addition to the custom properties (or “props”) you can define and set on your components, you can also pass all other props (HTML attributes) to an element of your choosing within your component.
This is very useful as it means that you can use regular HTML attributes to listen for events and even use frameworks like htmx and Alpine.js with your components.
Take this simple example of a custom button. Notice how we’re passing the rest of the props – obtained using the spread operator – to the button
element. This allows us to listen for the onclick
event and display an alert dialogue in response to it.
MyButton.component.js
export default function ({label = 'Missing label prop (label="…")', ...props}) {
return kitten.html`
<div class='MyButton'>
<button class='MyButton' ...${props}>${label}</button>
<style>
.MyButton button {
border: 2px solid darkviolet;
color: darkviolet;
border-radius: 0.25em;
padding: 0.25em 0.5em;
background: transparent;
font-size: 2em;
}
</style>
</div>
`
}
index.page.js
import MyButton from './MyButton.component.js'
export default () => kitten.html`
<h1>My button</h1>
<${MyButton} label='Press me!' onclick='alert("Thank you for pressing me!")' />
`
If, instead you wanted to use Alpine.js, this is how your index page would look:
import MyButton from './MyButton.component.js'
export default () => kitten.html`
<page alpinejs>
<h1>My button</h1>
<${MyButton}
label='Press me!'
x-data
@click='alert("Thank you for pressing me!")'
/>
`
💡For more advanced components, you can, of course, use htmx and Alpine.js inside your component and expose just your custom interface.
Slots
You can also pass content into components using a special attribute called a slot.
For example, if you have the following component:
Box.component.js
export default ({lineStyle = 'solid', SLOT}) => kitten.html`
<div class='Box'>
${SLOT}
</div>
<style>
.Box {
padding: 1em;
border: 1px ${lineStyle} CornflowerBlue;
}
.Box em { color: DarkViolet; }
</style>
`
You can slot content into it like this from your page:
index.page.js
import Box from './Box.component.js'
export default () => kitten.html`
<h1>Page</h1>
<p>This is the page.</p>
<${Box} lineStyle=dashed>
<h2>Slotted content</h2>
<p class='override'>This is <em>slotted content</em> from the page.</p>
</>
<p>This is the page again.</p>
<style>
body { font-family: sans-serif; }
/* Example of using the cascade to override styles in components for slotted content. */
.override em { color: DeepPink; }
</style>
`
Layout components
Without layout components
Slots become especially useful for layout.
Imagine you have a basic site with three pages: the home page, an about page, and a contact page. For consistency, the pages should share the same header, navigation, and footer.
First, let’s take a look at how we’d achieve this without using layout components and slots:
pages.script.js
// The data model for our site.
export const [ HOME, ABOUT, CONTACT_ME ] = ['home', 'about', 'contact-me']
export const pages = {
[HOME]: { title: 'Home', link: '/' },
[ABOUT]: { title: 'About', link: '/about' },
[CONTACT_ME]: { title: 'Contact me', link: '/contact-me' }
}
Header.component.js
import { pages } from './pages.script.js'
import Navigation from './Navigation.component.js'
export default ({ pageId }) => kitten.html`
<header class='Header'>
<${Navigation} pageId=${pageId} class='navigation'/>
<h1>${pages[pageId].title}</h1>
</header>
<style>
.Header { border-bottom: 1px solid gray; }
.Header .navigation {
color: white;
padding: 1em;
background-color: cadetblue;
}
</style>
`
Navigation.component.js
import { pages } from './pages.script.js'
export default ({ pageId, CLASS }) => kitten.html`
<nav class='Navigation ${CLASS}'>
<ul>
${Object.entries(pages).map(([__pageId, page]) => kitten.html`
<li>
<if ${ __pageId === pageId }>
<span class='currentPage'>${page.title}</a>
<else>
<a href='${page.link}'>${page.title}</a>
</if>
</li>
`)}
</ul>
</nav>
<style>
.Navigation { background-color: red; }
.Navigation ul { list-style-type: none; display: flex; padding: 0; }
.Navigation li:not(:first-of-type) { margin-left: 1em; }
.Navigation a { color: white; }
</style>
`
index.page.js
import Header from './Header.component.js'
import Footer from './Footer.component.js'
import { HOME } from './pages.script.js'
export default () => kitten.html`
<${Header} pageId=${HOME} />
<main>
<markdown>
## Welcome to my home page!
There are many home pages but this one is mine.
I hope you enjoy it.
</markdown>
</main>
<${Footer} />
`
about.page.js
import Header from './Header.component.js'
import Footer from './Footer.component.js'
import { ABOUT } from './pages.script.js'
export default () => kitten.html`
<${Header} pageId=${ABOUT} />
<main>
<markdown>
## Hey, look, it’s me!
Information about me.
</markdown>
</main>
<${Footer} />
`
contact-me.page.js
import Header from './Header.component.js'
import Footer from './Footer.component.js'
import { CONTACT_ME } from './pages.script.js'
export default () => kitten.html`
<${Header} pageId=${CONTACT_ME} />
<main>
<markdown>
## Get in touch!
Normally, there’d be a form here. Look in the guestbook and markdown examples if you want to see examples of such forms.
</markdown>
</main>
<${Footer} />
`
So while this works, you probably see a couple of problem areas:
- While we’ve encapsulated the navigation component in the header component, we still have to manually add the header and footer components to every page. That’s error-prone redundancy that we should refactor out.
- Where would we put styles that we want to affect all our pages? We don’t really have a place for that.
Enter layout components.
With layout components
Let’s start our refactor by pulling out the common page structure into a layout component:
Site.layout.js
import Header from './Header.component.js'
import Footer from './Footer.component.js'
export default ({ pageId, SLOT }) => kitten.html`
<${Header} pageId=${pageId} />
<main>
${SLOT}
</main>
<${Footer} />
`
💡Kitten supports a separate extension, layout.js, which is just an alias for component.js, if you want to make it clear that you’re using a component for layout.
Now that we have a layout component with a slot for our content, let’s refactor the pages to use it:
index.page.js
import Site from './Site.layout.js'
import { HOME } from './pages.script.js'
export default () => kitten.html`
<${Site} pageId=${HOME}>
<markdown>
## Welcome to my home page!
There are many home pages but this one is mine.
I hope you enjoy it.
</markdown>
</>
`
about.page.js
import Site from './Site.layout.js'
import { ABOUT } from './pages.script.js'
export default () => kitten.html`
<${Site} pageId=${ABOUT}>
<markdown>
## Hey, look, it’s me!
Information about me.
</markdown>
</>
`
contact-me.page.js
import Site from './Site.layout.js'
import { CONTACT_ME } from './pages.script.js'
export default () => kitten.html`
<${Site} pageId=${CONTACT_ME}>
<markdown>
## Get in touch!
Normally, there’d be a form here. Look in the guestbook and markdown examples if you want to see examples of such forms.
</markdown>
</>
`
Well, that’s a bit nicer.
💡
${SLOT}
is shorthand for${SLOT.default}
. As you’ll see later, slots also support named slots andSLOT.default
is a special slot that stacks all slotted content that isn’t addressed to a specific named slot.
Now, if we want to add global styles, the layout component becomes a natural place to do it:
import Header from './Header.component.js'
import Footer from './Footer.component.js'
export default ({ pageId, SLOT }) => kitten.html`
<${Header} pageId=${pageId} />
<main>
${SLOT}
</main>
<${Footer} />
<style>
body {
font-family: system-ui, sans-serif;
padding: 1em;
}
</style>
`
But what if we wanted to style the header or footer component?
Passing CSS class lists to components
In Kitten, you can declare a class (or class list) on a custom component just like you can on any other HTML element, using the class
attribute.
So if we want to change all the text in the header so that it displays in small caps and add a bit of top margin between the footer and the rest of our page, we can do that like this:
import Header from './Header.component.js'
import Footer from './Footer.component.js'
export default ({ pageId, SLOT }) => kitten.html`
<${Header} pageId=${pageId} class='header' />
<main>
${SLOT}
</main>
<${Footer} class='footer' />
<style>
body {
font-family: system-ui, sans-serif;
padding: 1em;
}
.header {
font-variant: small-caps;
}
.footer {
margin-top: 2em;
}
</style>
`
However, to make it work, we also have to modify the header and footer components to add the class we’ve specified to the class attribute of their root element.
Kitten passes the class attribute as a special CLASS
property to your component. All magic Kitten properties are in UPPERCASE to differentiate them from the properties you declare and use while authoring and to make them stand out.
Here are the relevant parts of the header and footer components after the changes have been made:
header.component.js
export default ({ pageId, CLASS }) => kitten.html`
<header class='Header ${CLASS}'>
<${Navigation} pageId=${pageId} class='navigation'/>
<h1>${pages[pageId].title}</h1>
</header>
…
`
footer.component.js
export default ({ pageId, CLASS }) => kitten.html`
<header class='Header ${CLASS}'>
<${Navigation} pageId=${pageId} class='navigation'/>
<h1>${pages[pageId].title}</h1>
</header>
`
🐈 The idiom in Kitten is to pass CSS classes to components and have them apply to the component’s root element. Having a root element for components is also a Kitten idiom. However, you can have components without a root element (with multiple sibling elements) and, if you really want to, you can apply the passed class list to any element or to multiple elements. It just might get a bit harder to follow what’s happening and to maintain your code if you do.
💡 You can see this project in the layout folder under examples.
To round out the layout example, let’s see how you’re not limited to just the one default slot.
Using named slots, you can have as many as you like.
Named slots
Say we wanted pages to be able to add or change elements in the heading and the footer.
We can do that using named slots.
Site.layout.js
import Header from './Header.component.js'
import Footer from './Footer.component.js'
export default ({ pageId, SLOT }) => kitten.html`
<${Header} pageId=${pageId} class='header'>
${SLOT.header}
</>
<main>
${SLOT}
</main>
<${Footer} class='footer'>
${SLOT.footer}
</>
<style>
body {
font-family: system-ui, sans-serif;
padding: 1em;
}
.header {
font-variant: small-caps;
}
.footer {
margin-top: 2em;
}
</style>
`
Note that we’ve added two named slots, header
and footer
and referenced them via the SLOT
property as SLOT.header
and SLOT.footer
.
Also note that we’re actually placing these as default slots in the header and footer components.
The Site layout’s default slot remains unchanged.
To make this work, let’s implement slot support in the header and footer components.
Header.component.js
import { pages } from './pages.script.js'
import Navigation from './Navigation.component.js'
export default ({ pageId, SLOT, CLASS }) => kitten.html`
<header class='Header ${CLASS}'>
<${Navigation} pageId=${pageId} class='navigation'/>
<h1>${pages[pageId].title}</h1>
${SLOT}
</header>
<style>
.Header {
border-bottom: 1px solid gray;
}
.Header .navigation {
color: white;
padding: 1em;
background-color: cadetblue;
}
</style>
`
Footer.component.js
export default ({ SLOT, CLASS }) => kitten.html`
<footer class='Footer ${CLASS}'>
${SLOT}
<markdown>
Copyright (c) 2023-present, Me.
The content on this site is released under [Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0)](https://creativecommons.org/licenses/by-nc-sa/4.0/).
The source code of this site is released under [GNU AGPL 3.0](https://www.gnu.org/licenses/agpl-3.0.en.html).
Powered with love by Kitten. 🐱 💕
</markdown>
</footer>
<style>
.Footer {
border-top: 1px solid gray;
padding-top: 1em;
text-align: center;
font-size: small;
}
.Footer p { margin: 0.25em; }
</style>
`
Finally, let‘s create some content to go in these named slots.
On the About page, we’ll add a little shout-out to our funding page. And on the Contact Me page, let’s add a notice to the header that we’re on holiday so no one tries to contact us.
about.page.js
import Site from './Site.layout.js'
import { ABOUT } from './pages.script.js'
export default () => kitten.html`
<${Site} pageId=${ABOUT}>
<markdown>
## Hey, look, it’s me!
Information about me.
</markdown>
<content for='footer'>
<div class='funding'>
<p>If you want to fund my work, you can donate to <a href='https://small-tech.org/fund-us'>Small Technology Foundation</a></p>
</div>
</content>
</>
<style>
.funding {
padding: 1em;
margin-bottom: 1em;
background-color: aquamarine;
border-radius: 1em;
}
.funding a {
color: white;
}
</style>
`
contact-me.page
import Site from './Site.layout.js'
import { CONTACT_ME } from './pages.script.js'
export default () => kitten.html`
<${Site} pageId=${CONTACT_ME}>
<markdown>
## Get in touch!
Normally, there’d be a form here. Look in the guestbook and markdown examples if you want to see examples of such forms.
</markdown>
<content for='header'>
<p class='awayMessage'>I’m away on holiday at the moment</p>
</content>
</>
<style>
.awayMessage {
text-align: center;
font-weight: bold;
font-size: 1.5em;
background-color: yellow;
}
</style>
`
Notice that we’ve kept the navigation component in the header component which means that when we override the header’s styles in the Site layout component to make the title display in in small caps, the navigation’s font changes also.
What if we wanted to be able to style the navigation separately in our layout template?
Well, we could create a new named slot in the header component that inserts content about the heading and use that to pass in the navigation component. Then, we could override the style in the layout component.
Let’s do that as our final refactor:
Header.component.js
import { pages } from './pages.script.js'
export default ({ pageId, SLOT, CLASS }) => kitten.html`
<header class='Header ${CLASS}'>
${SLOT.aboveHeading}
<h1>${pages[pageId].title}</h1>
${SLOT}
</header>
<style>
.Header {
border-bottom: 1px solid gray;
}
</style>
`
Site.layout.js
import Header from './Header.component.js'
import Navigation from './Navigation.component.js'
import Footer from './Footer.component.js'
export default ({ pageId, SLOT }) => kitten.html`
<${Header} pageId=${pageId} class='header'>
<content for='aboveHeading'>
<${Navigation} pageId=${pageId} class='navigation'/>
</content>
${SLOT.header}
</>
<main>
${SLOT}
</main>
<${Footer} class='footer'>
${SLOT.footer}
</>
<style>
body {
font-family: system-ui, sans-serif;
padding: 1em;
}
.header {
font-variant: small-caps;
}
.navigation {
font-variant: none;
color: white;
padding: 1em;
background-color: cadetblue;
}
.footer {
margin-top: 2em;
}
</style>
`
Note that if you wanted the content slotted into the header to go above the title instead of below it, you could have moved ${SLOT.header}
inside the <content for='aboveHeading'>…</content>
tag.
You can basically nest named and default slots any way you like that makes sense for the design of your site or app and best expresses your intent.
💡 You can even have multiple content areas target the same named slot and, just like the default slot, they will be stacked in the order in which they were encountered in your component or page.
Special page slots
In addition to the slots you can define and use while authoring Kitten apps, there are a handful of special preset slots you can use to send content into places you wouldn’t otherwise be able to reach from your pages as they lie outside where your page is rendered in Kitten’s internal base layout component.
🐈 The internal base layout component is fully customisable.
The list of special page slots supported by kitten are HTML
, HEAD
, START_OF_BODY
, BEFORE_LIBRARIES
, AFTER_LIBRARIES
, and END_OF_BODY
.
💡 The
HTML
slot is unique in that you are expected to provide just the opening<html>
tag, not the full document. It’s there so you can specify a different language (e.g.,<html lang=uk'>
for Ukrainian). Do not return anything else in the content for this slot or the sky will fall on your head. 🙀
Here’s an example, showing you most of the special page slots in use:
index.page.js
export default () => kitten.html`
<page htmx alpinejs>
<content for='HEAD'>
<title>Special page slots</title>
<link rel='icon' href='/favicon.ico'>
</content>
<h1>Special page slots</h1>
<h2>This is just regular content on the page.</h2>
<content for='START_OF_BODY'>
<p>This is the <strong>start of the body</strong>.</p>
</content>
<content for='BEFORE_LIBRARIES'>
<p>This is right <strong>before Kitten libraries</strong> (i.e., htmx and Alpine.js) are declared.</p>
</content>
<content for='AFTER_LIBRARIES'>
<p>This is right <strong>after Kitten libraries</strong> are declared. It’s a good place to put scripts if you want to make sure the Kitten libraries are loaded and can be used.</p>
</content>
<content for='END_OF_BODY'>
<p>This is the <strong>end of the body.</strong></p>
</content>
<style>
body { font-family: system-ui, sans-serif; }
p { padding: 1em; border: 0.25em solid deeppink; border-radius: 0.5em; }
</style>
`
Notice how you can use the HEAD
slot to set the title and specify an icon. Kitten has intelligent defaults for these that it uses if you forget (it tries to get the title from your page if you have a h1
tag and it sets the favicon to an empty one so you don‘t get load errors in your developer console, etc.)
🐈 Just like regular slots, you can specify more than one of the special page slots and any content you send there will stack.
You can also specify certain <head>
properties in the <page>
tag.
These are the title
, charset
, viewport
, and icon
properties. For anything else you want to add to the head of your web page, please use the special HEAD
page slot.
💡 When a
HEAD
slot exists, it has priority over properties defined in<page>
tags.
For example, you could also have written the above example as follows:
export default () => kitten.html`
<page htmx alpinejs title='Special page slots' icon='/favicon.ico'>
<content for='HEAD'>
<title>Special page slots</title>
<link rel='icon' href='/favicon.ico'>
</content>
<h1>Special page slots</h1>
…
`
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.
💡 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.
💡 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.
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.
index.page.js
Let’s start with the code for our new index page:
import Post from './Post.component.js'
export default async function route (_request, _response) {
const response = await fetch('https://mastodon.ar.al/api/v1/timelines/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(post => kitten.html`<${Post} post=${post} />`)}
</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(post => kitten.html`<${Post} post=${post} />`)}
</ul>
Specifically:
-
It has an
id
. We will be using thisid
from 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>
💡 Kitten has built-in support for htmx, the htmx WebSockets extension, and Alpine.js. It exposes the
HTMX
,HTMX_WEBSOCKET
, andALPINEJS
constants for you globally to use when returning alibraries
array from your page routes.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.
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.
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 kitten.html`
<li class='Post component'>
<${Avatar} post=${post} />
<${Content} post=${post} />
</li>
<style>
.Post {
display: flex; align-items: flex-start; column-gap: 1em; padding: 1em;
margin-bottom: 1em; background-color: #ccc; border-radius: 1em;
}
</style>
`
}
// Private components (can only be used by Post).
const Avatar = ({ post }) => kitten.html`
<a class='Avatar component' href='${post.account.url}'>
<img src='${post.account.avatar}' alt='${post.account.username}’s avatar' />
</a>
<style>
.Avatar img {
width: 8em;
border-radius: 1em;
}
</style>
`
const Content = ({ post }) => kitten.html`
<div class='Content component'>
${kitten.safelyAddHtml(post.content)}
${post.media_attachments.map(media => (
media.type === 'image' && kitten.html`<img src='${media.url}' alt='${media.description}'>`
))}
</div>
<style>
.Content { flex: 1; }
.Content p:first-of-type { margin-top: 0; }
.Content p { line-height: 1.5; }
.Content a:not(.Avatar) {
text-decoration: none; background-color: rgb(139, 218, 255);
border-radius: 0.25em; padding: 0.25em; color: black;
}
.Content img { max-width: 100%; }
/* Make sure posts don’t overflow their containers. */
.Content a {
word-break: break-all;
}
</style>
`
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
, orContent
components 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.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).
import Post from './Post.component.js'
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 kitten.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 = kitten.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 from Aral’s Mastodon server. 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.
💡 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 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 = kitten.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.
WebSocket Echos
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 WebSocket echo server that simply returns what we send it.
First, let’s create the page:
index.page.js
export default () => kitten.html`
<page htmx htmx-websocket water>
<main
hx-ext='ws'
ws-connect='/echo.socket'
>
<h1>WebSocket Echo</h1>
<form id='message-form' ws-send>
<label for='message'>Message</label>
<input id='message' name='message' type='text' required>
<button type='submit'>Send</button>
</form>
<ul id='echos' hx-swap-oob='beforeend'></ul>
</main>
`
The only new thing here is that we have a form with the ws-send
And now, let’s add the WebSocket route:
echo.socket.js
export default function (socket, request) {
console.log('Echo socket: new client connection.', request.session)
socket.addEventListener('message', event => {
const message = JSON.parse(event.data).message
socket.send(`
<ul id='echos' hx-swap-oob='beforeend'>
<li><strong>“${message}”</strong> received on server.</li>
</ul>
`)
})
}
And that’s it. Now run the example and you should see your messages being echoed back to you.
Enhancing usability with Alpine.js
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 using Kitten’s built-in support for Alpine.js.
First, update the <page>
tag so Kitten knows to include Alpine.js on the page:
<page htmx htmx-websocket water alpinejs>
Then, update the <input>
element so it clears itself after the message has been sent:
<form id='message-form' ws-send>
…
<input
id='message' name='message' type='text' required
x-data
@htmx:ws-after-send.window='$el.value = ""'
>
</form>
So what’s happening here is that we’re using Alpine’s @
syntax to specify an inline handler for htmx’s ws-after-send
event.
💡 Remember that we can only use Alpine.js within Alpine.js components and the way you designate an Alpine.js component is to declare an
x-data
attribute on it. In this case, we have no actual data the component has to manage so we just use an emptyx-data
tag.
While htmx supports both camelCase
and kebap-case
for event names, Alpine.js can only work with kebap case since attribute names are case insensitive in HTML5. So while you will see events in the former style in the htmx documentation, remember to use kebap case when listening to htmx events using Alpine.js.
💡 The @ syntax for defining event handlers is a shortcut for Alpine’s more verbose
x-on
attribute so we could also have written that line as:<input … x-on:htmx:ws-after-send.window='$el.value = ""'>
That said, given htmx uses a namespace (
htmx:
) that also contains a colon, you might find the shorthand@
syntax easier to author and read.
We also specify that we want to listen to the event on the window as it is dispatched from the parent <form>
node so it would otherwise not reach the child <input>
node (events in JavaScript bubble up, not down).
💡 To find out more about how Alpine.js works, make sure you read the Alpine.js documentation. It’s got a tiny API and you can likely work through the whole documentation in less than an hour.
Send a few more messages and notice that sent messages are now cleared from the input box.
Ah, that’s better! 😻
Kitten chat
You can do a lot by using a WebSocket server to communicate with your application. You can use it, for example, in place of POST requests to send remote commands to the server and get asynchronous results back. That’s really powerful in creating responsive applications. But you’re not limited to sending messages to just one page. You can also broadcast messages to all connected pages.
To see how that works, let’s create a simple chat example, starting with the index page:
index.page.js
import styles from './index.styles.js'
// 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).
export default () => kitten.html`
<page htmx htmx-websocket>
<main>
<h1>🐱 <a href='https://codeberg.org/kitten/app'>Kitten</a> Chat</h1>
<div
id='chat'
hx-ext='ws'
ws-connect='/chat.socket'
x-data
>
<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' type='text' required
@htmx:ws-after-send.window='$el.value = ""'
>
<label for='text'>Message:</label>
<input id='text' name='text' type='text' required />
<button id='sendButton' type='submit'>Send</button>
</form>
</div>
</main>
${[styles]}
`
Also, create an index.styles.js 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.
🐈 We’ve wrapped our entire interface a
<main>
tag. This is because we are going to add styles that wouldn’t work when applied to the<body>
tag that Kitten renders your page onto. You might have seen mainstream web frameworks render pages into a special div. Kitten tries to keep special cases to a minimum and doesn’t do that.
💡 We could have just inlined the styles into the HTML block but since they’re quite verbose, we decided to put them into their own file. Note that the syntax we’re using to include (interpolate) the styles into the HTML. By wrapping them in an array, we are telling HTMX to bypass any sanitisation it may otherwise perform on interpolated values. Needless to say, only do this with trusted content and never with data you obtain from an API call, etc. In those instances, use the global
kitten.safelyAddHtml()
function.
index.styles.js
export default kitten.css`
* { box-sizing: border-box; }
/* Make interface fill full browser canvas. */
main {
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;
}
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;
}
/* The chat div should not affect the layout. This is actually a smell.
It is purely being used to declaratively create a WebSocket connection.
Need to think about this.
*/
#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 route
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(
kitten.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
<div>
(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 some more custom logic on the client using Alpine.js.
Let’s start with 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.js to add a placeholder list item to the #messages
list:
<div
id='chat'
hx-ext='ws'
ws-connect='/chat.socket'
x-data='{ showPlaceholder: true }'
>
<ul id='messages' @htmx:load='showPlaceholder = false'>
<li id='placeholder' x-show='showPlaceholder'>
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 unordered list receives htmx:load
events whenever new data (a list item) is loaded. When it hears this, it sets the showPlaceholder
flag to false
. The placeholder list item is shown or hidden based on the state of this flag using the x-show
attribute.
The flag itself is declared in the parent <div>
using an x-data
attribute.
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 Alpine code to make the chat section scroll to the bottom whenever a new message is received:
<div
id='chat'
hx-ext='ws'
ws-connect='/chat.socket'
x-data='{ showPlaceholder: true }'
>
<ul id='messages' @htmx:load='
showPlaceholder = false
$el.scrollTop = $el.scrollHeight
'>
<li id='placeholder' x-show='showPlaceholder'>
No messages yet, why not send one?
</li>
</ul>
…
</div>
Getting syntax highlighting for Alpine.js
It might be that your editor supports syntax highlighting for embedded Alpine.js code by default. If so, that’s great and you can skip this section.
If it doesn’t, there’s a little trick you can use to get it in any editor that supports syntax highlighting for tagged template literals. Kitten comes with a simple one called kitten.js
that simply passes through anything it’s passed. If you use it, however, you will get syntax highlighting for embedded code in editors like Helix Editor. It does add complexity to the code, however, and gives you yet another quote mark that you might have to escape if it appears in your code. For larger pieces of code, it is best to use a standard JavaScript file and include it at runtime in your page.
All that said, this is what the above code would look like if you used it:
<div
id='chat'
hx-ext='ws'
ws-connect='/chat.socket'
x-data='${kitten.js`{ showPlaceholder: true }`}'
>
<ul id='messages' @htmx:load='${kitten.js`
showPlaceholder = false
$el.scrollTop = $el.scrollHeight
`}'>
<li id='placeholder' x-show='showPlaceholder'>
No messages yet, why not send one?
</li>
</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 Alpine.js to achieve this:
StatusIndicator.component.js
export default () => kitten.html`
<p>Status: <span
id='status'
x-data='{ status: "Initialising…" }'
@htmx:ws-connecting.window='status = "Connecting…"'
@htmx:ws-open.window='status = "Online"'
@htmx:ws-close.window='status = "Offline"'
x-text='status'
:class='
status === "Online" ? "online" : status === "Offline" ? "offline" : ""
'>
Initialising…
</span></p>
<style>
.online {color: green}
.offline {color: red}
</style>
`
Finally, let’s add the StatusIndicator
component to our index.page.js:
import styles from './index.styles.js'
import StatusIndicator from './StatusIndicator.component.js'
export default () => kitten.html`
<h1>🐱 <a href='https://codeberg.org/kitten/app'>Kitten</a> Chat</h1>
<${StatusIndicator} />
<div
id='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 turns 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.
Debugging htmx
If some of the htmx stuff seams rather opaque and magical to you, that’s because, to a degree, it is. Making what htmx is doing visible by logging it might help you to debug your site or app when things go awry.
To that end, let’s implement an htmx logger.
While Alpine.js lets you do commonly done things easily, it’s sometimes easiest just to pop out into plain old JavaScript for more convoluted things.
Since this is just a regular web page, you can add any number of <script>
tags to it and load in external JavaScript by specifying its path.
So let’s add a client-side .js file and implement our htmx logger in that.
💡 Note that .js files are just regular client-side JavaScript files. They are served from your server like any other static file. Make sure you don’t put anything sensitive (like API keys, etc.) in them.
index.js
function onLoad() {
// For debugging: log out htmx events to the console.
htmx.logger = function(elt, event, data) {
if(console) {
console.log(event, elt, data)
}
}
}
window.addEventListener('load', onLoad)
💡 The
htmx
global is available when htmx is included on the page so we wait for the document‘s load event before attempting to attach the debugger.
Of course, just creating our script file isn’t enough, we also need to load it from the page. We could just add it to the end of our page (maybe after the closing <main>
tag) but then we’d have a problem: we couldn’t guarantee that it gets loaded after htmx itself does. (And our logger function relies on htmx having loaded so that the htmx
global is available.)
What we really want is to add our script to the page after libraries like htmx have loaded. We can do this using the special page slots and the special Kitten tag called <content>
which we saw earlier in the Layout Components section:
…
</main>
<content for='AFTER_LIBRARIES'>
<script src='./index.js' />
</content>
${[styles]}
💡 If we wanted to use ES6 Modules in our script, we’d have to add
type='module'
to our script tag.
Adding final touches for mobile devices using custom JavaScript
Now that we have a place to put arbitrary client-side JavaScript, let’s use a trick – also known as “a hacky workaround” (oh, hello, welcome to web development) – to ensure our interface displays correctly even when the address bar is visible in mobile browsers.
Update your index.js file to match the following:
index.js
function fixInterfaceSizeOnMobile () {
function resetHeight() {
let vh = window.innerHeight * 0.01;
document.querySelector(':root').style.setProperty('--vh', `${vh}px`)
}
resetHeight()
window.onresize = function (_event) {
resetHeight()
}
}
function onLoad () {
fixInterfaceSizeOnMobile()
// For debugging: log out htmx events to the console.
htmx.logger = function(elt, event, data) {
if(console) {
console.log(event, elt, data)
}
}
}
window.addEventListener('load', onLoad)
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
.
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 Alpine.js. Modify #text
input in the #message-form
as shown below:
<form id='message-form' ws-send>
…
<input
id='text' name='text' type='text' required
@htmx:ws-after-send.window='
$el.value = ""
$el.focus()
'
/>
…
</form>
💡 Notice that we’re listening for the event a little differently here. Instead of listening for the
@htmx:ws-after-send
event on the<input>
element itself, we’re using the.window
modifier to listen for it on the window. That’s because the event is dispatched from the<form>
element that contains thews-send
attribute. And that element is our parent so events dispatched from there will not bubble down to us (events in JavaScript bubble up). But the event will bubble up from there to the window so that’s where we listen for it.
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 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 Alpine code.
First, add a function called isMobile()
to index.js:
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 Alpine.js.
Now, modify your index.page.js 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' type='text' required
@htmx:ws-after-send.window='
$el.value = ""
// On mobile we don’t refocus the input box
// in case there is a virtual keyboard so
// the person can see the message they just sent.
if (!isMobile()) { $el.focus() }
'
>
…
</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.
Persisted Kitten Chat
While we were able to improve the usability of the Kitten Chat example by sprinkling a little Alpine.js 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.js
export default function Message ({ message }) {
return kitten.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 (chat.socket.js) 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.js
import Message from './Message.component'
// Ensure the messages table exists in the database before using it.
if (kitten.db.messages === undefined) kitten.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.
kitten.db.messages.push(message)
const numberOfRecipients = socket.all(
kitten.html`
<div hx-swap-oob="beforeend:#messages">
<${Message} message='${message}' />
</div>
`
)
// …
}
}
// …
💡 Messages sent by the htmx WebSocket extension include a htmx-specific
HEADERS
object.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
nickname
andtext
so we delete theHEADERS
object 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.js
import styles from './index.styles.js'
import Message from './Message.component.js'
import StatusIndicator from './StatusIndicator.component.js'
// Ensure the messages table exists in the database before using it.
if (kitten.db.messages === undefined) kitten.db.messages = []
export default () => kitten.html`
…
<div
id='chat'
hx-ext='ws'
ws-connect='/chat.socket'
x-data='{ showPlaceholder: true }'
>
<ul id='messages' @htmx:load='
showPlaceholder = false
$el.scrollTop = $el.scrollHeight
'>
<if ${kitten.db.messages.length === 0}>
<then>
<li id='placeholder' x-show='showPlaceholder'>
No messages yet, why not send one?
</li>
<else>
${kitten.db.messages.map(message => kitten.html`<${Message} message=${message} />`)}
</if>
</ul>
…
</div>
`
💡 Since the conditional logic in our template is somewhat verbose, I chose to use Kitten’s
<if>
conditional. The thing to be aware of here is that, due to JavaScript’s language limitations, the<if>
conditional cannot short circuit (so every branch of the conditional is evaluated, even if it is false, even if only the the true branch is displayed). This means that if you try to access a property on an object that is null or undefined in the falsey branch, your app will return an error. In this case, it is not a problem but, if it way, you would either use chained optionals in all your branches or one of the following JavaScript-only conditional methods.The first one, which resembles Kitten’s
<if>
syntax the most, is to use an immediately-invoked closure:${(messages => { if (messages.length === 0) { return kitten.html` <li id='placeholder' x-show='showPlaceholder'> No messages yet, why not send one? </li> ` } else { return messages.map(message => kitten.html`<${Message} message=${message} />`) } })(kitten.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 kitten.html` <li id='placeholder' x-show='showPlaceholder'> No messages yet, why not send one? </li> ` } else { return messages.map(message => kitten.html`<${Message} message=${message} />`) } })(kitten.db.messages)}
Or, if that’s still confusing, you can always use a conditional (ternary) operator. Notice how you refer to
kitten.db.messages
directly if you do this.${ kitten.db.messages.length === 0 ? kitten.html` <li id='placeholder' _='on htmx:load from #chat hide me'> No messages yet, why not send one? </li> ` : kitten.db.messages.map(message => kitten.html`<${Message} message=${message} />`) }
All three of these approaches are equivalent and, since they use native JavaScript statements, all of them short circuit. 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. 🐱)
Project-specific secret
Kitten automatically generates a cryptographically-secure secret for each project.
For the cryptographers among you, this is a base256-encoded ed25519 private key. For everyone else, it’s a lovely string of 32 emoji that looks something like this:
🌻🦝🍰🐨🍮🌼🍙🦃🦚🦈🪴🍆🙉🐘🌮🌍🧇🐊🦁🐂🐧🐨🐁🐙🐧🥜🐵🐮🥔🦘🍓🐻
This secret is shown to you only the very first time you run Kitten on a given project (folder).
If you’re wondering how in the world you are going to type that in, don’t worry: you’re not supposed to be able to by design.
Instead, please add this secret to your password manager of choice.
If you implement authenticated routes in your application, you can use your password manager to enter your secret for you.
For technical details, please see the Cryptographical Properties section.
Authenticated Routes
To signal to Kitten that a route is only available when the person is authenticated, you add 🔒
to the end of the route name (that’s a lock emoji).
Kitten itself has just a route that’s available to all apps/sites created with Kitten at:
settings🔒/
If you hit the /settings route on any Kitten app/site, you’ll be automatically redirected to the /sign-in route if you’re not authenticated and you only see the settings section if you are.
Adding the suffix to a directory, as shown here, ensures that it applies to all routes in that directory.
You can also add it to specific routes (files).
HTTP Routes
We’ve seen examples of simple Kitten apps that use pages and WebSockets, but what if you want to POST data from a web form, implement Ajax with fragments of HTML using htmx, or create an Application Programming Interface (API) that returns JSON?
Enter HTTP Routes.
(OK, technically speaking, everything is an HTTP route but that’s the terminology we use in Kitten to separate Pages from, well, every other HTTP route except WebSocket routes.)
Similar to how you create pages in .page.js files and WebSocket routes in .socket.js files, HTTP routes are declared using a naming convention based on their filename extension which can be any valid HTTP1/1.1 method in lowercase (e.g., .get.js, .post.js, .patch.js, .head.js, etc.)
HTTP Routes do not carry out any processing on whatever value you return for them.
So you can return a fragment of HTML (e.g., a component) if you’re implementing Ajax, or use JSON.stringfy()
to return a JSON repsonse, etc.
e.g.,
my-project
├ index.page.js
├ index.post.js
├ about
│ ╰ index.page.js
├ todos
│ ╰ index.get.js
╰ chat
╰ index.socket.js
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.js
│ ├ index.post.js
│ ├ index.socket.js
│ ╰ about
│ ╰ index.page.js
├ test
│ ╰ index.js
╰ README.md
POST/redirect/GET
One very common HTTP Route is POST, usually used when you want to send data back from a page and persist it.
The Guestbook example (examples/guestbook), demonstrates this pattern in the simplest possible way.
First, let’s create a page that will display the form for signing the guestbook and existing guestbook entries:
index.page.js
if (!kitten.db.entries) kitten.db.entries = []
export default () => kitten.html`
<h1>Guestbook</h1>
<h2>Sign</h2>
<form method='POST' action='/sign'>
<label for='message'>Message</label>
<textarea id='message' name='message' required></textarea>
<label for='name'>Name</label>
<input type='text' id='name' name='name' required />
<button type='submit'>Sign</button>
</form>
<h2>Entries</h2>
${kitten.db.entries.length === 0 ?
kitten.html`<p>Hey, no one’s signed yet… be the first?</p>`
:''}
<ul>
${kitten.db.entries.map(entry => kitten.html`
<li>
<p class='message'>${entry.message}</p>
<p class='nameAndDate'>${entry.name} (${new Date(entry.date).toLocaleString()})</p>
</li>
`)}
</ul>
<style>
body { font-family: sans-serif; margin-left: auto; margin-right: auto; max-width: 20em; }
label { display: block; }
textarea, input[type='text'] { width: 100%; }
textarea { height: 10em; }
button { width: 100%; height: 2em; margin-top: 1em; font-size: 1em; }
ul { list-style-type: none; padding: 0; }
li { border-top: 1px dashed #999; }
.message, .nameAndDate { font-family: cursive; }
.message { font-size: 1.5em; }
.nameAndDate { font-size: 1.25em; font-style: italic; text-align: right; }
</style>
`
This is very straightforward. Notice that we have a form for signing the guestbook and it’s just plain HTML.
<form method='POST' action='/sign'>
<label for='message'>Message</label>
<textarea id='message' name='message' required></textarea>
<label for='name'>Name</label>
<input type='text' id='name' name='name' required />
<button type='submit'>Sign</button>
</form>
Its method
is set to POST
and its action is /sign
. That means that when the submit button is pressed, it will carry out an HTTP POST request to the /sign
route on our server.
In that route, we will save the new guestbook entry and then redirect the person’s browser back to the index page. This pattern of handling a POST request and then redirecting to a GET route (our pages are all GET routes), is called the POST/redirect/GET pattern.
So let’s create our POST route:
sign.post.js
if (!kitten.db.entries) kitten.db.entries = []
export default (request, response) => {
// Basic validation.
if (!request.body || !request.body.message || !request.body.name) {
return response.forbidden()
}
kitten.db.entries.push({
message: request.body.message,
name: request.body.name,
date: Date.now()
})
response.get('/')
}
And that’s it.
💡 Kitten has a number of request and response helpers defined to make your life easier. You just used two of them, above:
response.forbidden()
, which returns a HTTP 403: Forbidden error andresponse.get()
, which is an alias forresponse.seeOther()
, which returns and HTTP 303: See Other response.In this case, since we’re going a Post/Redirect/Get (PRG), using the
get()
alias makes the intent of our code clearer.You could also have manually handled the direction like this:
response.statusCode = 303 response.setHeader('Location', '/') response.end()
(Which is exactly what Kitten does internally when you use the
.get()
/.seeOther()
methods.)In addition to the methods you’ve already seen, Kitten also supports the following helpers:
Request
is (array|string)
: returns true/false based on whether thecontent-type
of the request matches the string or array of strings presented.This is used internally for Express Busboy compatibility but you might find it useful in your apps too if you’re doing low-level request handling.
Response
get (location)
,seeOther (location)
: 303 See Other redirect (always uses GET).
redirect (location)
, temporaryRedirect (location)`: 307 Temporary Redirect (does not change the request method).
permanentRedirect (location)
: 308 Permanent Redirect (does not change the request method)
badRequest (body?)
: 400 Bad Request.
unathenticated (body?)
,unauthorised (body?)
,unauthorized (body?)
: 401 Unauthorized (unauthenticated).
forbidden (body?)
: 403 Forbidden (request is authenticated but lacks sufficient rights – i.e., authorisation – to access the resource).
notFound (body?)
: 404 Not Found.
error (body?)
,internalServerError (body?)
: 500 Internal Server Error.
Run kitten
command on your project folder and visit https://localhost to see your guestbook.
💡 Notice that we’re doing some very basic validation to make sure that body of the request (which is where the form’s data is found) is as we expect it.
In case you’re worried about script inject, type
<script>alert("Hehe, I just hacked you!")</script>
in your message box. Try it out and see what happens. Kitten’s template engine automatically escapes interpolated string content to avoid such attacks. If you wanted to allow HTML through, Kitten provides a globalkitten.safelyAddHtml()
function you can call that sanisitises the input before allowing it. While it comes with intelligent defaults, you can also customise exactly what you want to let through or not.You can test out the basic server-side validation using a basic
curl
command. First, let’s send a bad request and see what we get. In this case, we’re not sending any data at all:curl --include --data-urlencode '' https://localhost/sign/
And we see that our validation works:
HTTP/1.1 403 Forbidden Access-Control-Allow-Origin: * Set-Cookie: sessionId=LbhwT0YqkVyAzQR0S-2KFGPa; Max-Age=28800000; Path=/; HttpOnly; Secure; SameSite=Strict Date: Fri, 11 Aug 2023 12:36:23 GMT Connection: keep-alive Keep-Alive: timeout=5 Content-Length: 0
(The
--include
flag is what tells curl to print out the response header we received.)Finally, let’s send a valid request and sign the guestbook from the command-line like proper nerds:
curl --include --data-urlencode 'message=From curl with love.' --data-urlencode 'name=Curl' https://localhost/sign/
This time, we get a much nicer response:
HTTP/1.1 303 See Other Access-Control-Allow-Origin: * Set-Cookie: sessionId=LMw_wcABaqRNWyhYmF7fvHFJ; Max-Age=28800000; Path=/; HttpOnly; Secure; SameSite=Strict Location: / Date: Fri, 11 Aug 2023 12:37:35 GMT Connection: keep-alive Keep-Alive: timeout=5 Content-Length: 0
It’s telling us that we should see the
/
route. So, let’s. Go back to your browser and refresh the main page and you should see the guestbook entry from curl.
Multipart forms and file uploads
Kitten has high-level support for multi-part form handling and file uploads.
Uploads sent to POST
routes via <input type='file'>
in your pages are automatically saved in your project’s uploads folder. Kitten automatically assigns them unique IDs and serves them from the /uploads/<unique-id>
route. The Upload objects are also available to your POST
routes in the request.uploads
array.
💡 An upload object has the following properties:
.id // Unique id. Used to look up uploads and calculate resource paths. .fileName // Name of the original file that was uploaded. .filePath // Absolute path to uploaded file on server. .resourcePath // Relative URL resource path the upload can be downloaded from. .mimetype // MIME type of file. .field // Name of file upload field in form that file was uploaded from. .encoding // Encoding of file. .truncated // Whether file was truncated or not (boolean). .done // Whether upload was successfully completed or not (boolean).
And the following method:
.delete() // Deletes the upload.
A common idiom is to save the upload’s unique ID (e.g., request.uploads[0].id
), along with any other data in your form (e.g., the alt-text of an image upload), in your own database tables. Then, when you want to, say, render an uploaded image on a page, you can use the global kitten.upoads
object to reference the upload you need and access its resource path.
e.g.,
kitten.html`
<img src='${kitten.uploads.get(uploadId).resourcePath}' alt='…'>
`
💡 The
kitten.uploads
collection has the following methods:.get(id) // Returns Upload object with given ID (or undefined, if it doesn’t exist). .all() // Returns array of all Upload objects. .allIds() // Returns array of strings of all Upload object IDs. .delete(id) // Deletes object with given id (or fails silently if it doesn’t exist).
Kitten can handle multiple file uploads as well as single ones.
💡Note that you must set the
enctype='multipart/form-data'
attribute on your forms for file uploads to work correctly.
The following basic example shows just how easy it is to handle file uploads in Kitten. In it, you can upload one image at a time along with its alt-text and displays them in a grid at the top of the page:
index.post.js
export default function (request, response) {
request.upoads.forEach(upload => {
kitten.db.images.push({
path: upload.resourcePath,
altText: request.body.altText ? request.body.altText : upload.fileName
})
})
response.get('/')
}
index.page.js
if (!kitten.db.images) kitten.db.images = []
export default () => kitten.html`
<h2>Uploaded images</h2>
<if ${kitten.db.images.length === 0}><p>None yet.</p></if>
<ul>
${kitten.db.images.map(image => kitten.html`
<img src=${image.path} alt=${image.altText}>
`)}
</ul>
<h2>Upload an image</h2>
<form method='post' enctype='multipart/form-data'>
<label for='image'>Image</label>
<input type='file' name='image' accept='image/*'>
<label for='alt-text'>Alt text</label>
<input type='text' id='alt-text' name='altText'>
<button type='submit'>Upload</button>
</form>
<style>
body { max-width: 640px; margin: 0 auto; padding: 1em; font-family: sans-serif; }
ul { padding: 0; display: grid; grid-template-columns: 1fr 1fr; }
img { max-height: 30vh; margin: 1em; }
input { width: 100%; margin: 1em 0; }
button { padding: 0.25em 1em; display: block; margin: 0 auto; }
</style>
`
You can find the code for the above example in examples/file-uploads.
End-to-end encrypted Kitten Chat
So now you’ve learned how to use WebSockets, authenticated routes, HTTP routes, and how to carry out global tasks using a main.script.js file in Kitten. How about we put it all together and sprinkle some of Kitten’s built-in cryptography support to create an end-to-end encrypted version of the Kitten Chat example.
💡 This is just a basic example of implementing end-to-end encryption. It is not meant to be used in production or in real world situations for private communication.
🚧 Some of the elements you see in this example (like the remote message emitter and the means of retrieving the public keys of remote servers and delivering messages to them) will be implemented in a production-ready manner in Kitten itself soon. Once this happens, I’ll either add a separate example that uses the built-in APIs or update this example to use them based on which I find more useful at the time.
Encryption and threat models
Nothing is entirely secure. Some things are secure enough.
In many situations, the best we can do is to raise the cost of surveillance to ensure that it is only used in specific cases (as opposed to mass surveillance).
End-to-end encryption is one of the means we have open to us for raising the cost of surveillance.
💡 It’s important to understand your threat model.
Kitten’s security model does not protect you against targetted surveillance by determined adversaries that could compromise your server to install and run their own compromised version of your Kitten app.
Barring any potential vulnerabilities in Kitten, if you are hosting your app using a commercial web host, this means that they will have had to infiltrate your web host and install an app that can steal your secret. This would normally require either a determined person working at the web host or a state-level actor.
If you’re hosting your app on your own hardware at a physical location you control, it would require the compromise of that location.
What end-to-end encryption mainly protects against is the opportunistic person at your web host being able to read your messages even if they compromise your machine.
Messages are already protected in transit via TLS. Their being end-to-end encrypted means that they are also protected at rest in the database on the server.
Since your secret remains on the client (the browser), implementing end-to-end encryption requires the use of JavaScript.
So, in this example, we will be making use of htmx and Alpine.js’s event handling to encrypt and decrypt messages in the browser using JavaScript. The server will only ever see the encrypted text (or as we call it in cyptography, the ciphertext) and never the unencrypted text (or plaintext.)
We’ll start from where we left off in the Persisted Kitten Chat example.
Private vs public routes
In the Persisted Kitten Chat example, we hadn’t implemented authentication and all our routes were public. Anyone could join the chat and, if they wanted to, even bypass our web interface and connect directly to our chat socket.
Needless to say, that’s not something you’d normally implement outside of a simple example for a tutorial.
For our end-to-end encrypted chat example, we need to decide which routes we keep public and which ones must be private.
To begin with, make a copy of the Persisted Kitten Chat example and let’s create a directory that will require any route placed in it require authentication:
mkdir private🔒
💡 We saw earlier that we can use the built-in authentication system in Kitten to easily create private routes by appending them with the lock emoji (🔒).
Now, let’s copy all the files that were previously public into our new private folder.
If you were to run the example now and hit https://localhost, you’d get the 404 Kitten since we no longer have an index.page.js in the root of our project (everything is in the private folder). So let’s add a simple index.html file in the root of our project with a link that takes us to the chat. This is a page anyone will be able to access:
index.html
<h1>Public site</h1>
<a href='/private'>End-to-end encrypted Kitten chat</a>
💡 Notice how we didn’t have to add the lock emoji in the link. Kitten is clever enough to strip it off when creating its route patterns.
Now, we’re going to run Kitten a little differently, by explicitly specifying the domain instead of using localhost:
kitten --domain=place1.localhost
We’re doing this because to test the end-to-end encrypted Kitten chat, we will need to fire up two different instances of the Kitten server so we can use them to chat. Since we’re testing locally, we need to use a subdomain that is an alias for localhost but will be treated as a different domain by browsers (this is to ensure that cookies are isolated between the instances as cookies are not isolated by port but by domain).
Kitten has built-in support for four localhost aliases to help you test the peer-to-peer features of Small Web apps (place1.localhost to place4.localhost).
💡 Subdomains on localhost Just Work (tm) on Linux but on macOS you have to manually edit your /etc/hosts file to map the subdomains to 127.0.0.1.
Once you’ve made the changes, your hosts file should resemble the one below:
## # Host Database # # localhost is used to configure the loopback interface # when the system is booting. Do not change this entry. ## 127.0.0.1 localhost place1.localhost place2.localhost 255.255.255.255 broadcasthost ::1 localhost
Now, hit https://place1.localhost and you should see the link to the private chat section.
💡 When the server starts, Kitten will generate a new secret for you. Note this down in your password manager now.
Follow the link on the page and you should reach Kitten’s automatically-generated Sign In page at /sign-in.
Enter the secret you had saved in your password manager to sign in.
💡 Did you forget to note it down in your password manager? That’s OK. If you ever forget the secret for a project you’re testing locally, you can follow the “Databases” link that Kitten displays when it’s run and delete the folder that holds the database for your project. The next time you run Kitten, it will recreate the password. Don’t forget to note it down this time :)
You should now see the chat interface from before and the chat should function exactly as the Persisted Kitten Chat example did.
Now, let’s change it so our messages are end-to-end encrypted.
private🔒/index.page.js
First, let’s update our interface based on how we envision the chat to work when end-to-end encryption is implemented.
Starting with the interface and working inwards from there is what’s known as “outside-in design.” It lets us concentrate on how our tool is going to be used first before we get bogged down on the implementation details.
The first change we’re going to make is to the <div>
tag where the htmx socket is defined.
There, we’re going to have htmx prevent the wsConfigSend
event and call our encryptMessage()
handler so we can encrypt the message before it is sent. (We will then manually send the message ourselves.)
<div
id='chat'
hx-ext='ws'
ws-connect='/private/chat.socket'
x-data='${kitten.js`{ showPlaceholder: true }`}'
@htmx:ws-config-send.prevent='encryptMessage'
>
The only other change we’re going to make is to change the names and labels of the form elements.
Since our app will now enable people at different domains to chat to each other securely, we need to specify which domain we are sending our message to. And, finally, we rename the message itself from text
to plainText
to make it very clear that this is the message before it is encrypted.
<form id='message-form' ws-send >
<label for='domain'>To:</label>
<input id='domain' name='domain' type='text' required />
<label for='plainText'>Message:</label>
<input id='plainText' name='plainText' type='text' required
…
/>
…
</form>
The rest of the page stays the same.
We’ll look at how we implement the encryptMessage()
function later but first, we’ve only specified when we should encrypt messages. We haven’t specified when we should decrypt them.
So let’s think about that next.
private🔒/Message.component.js
We mentioned earlier that the server only ever sees the ciphertext and never the plaintext. So we know that both encryption and decryption must take place on the client (in the browser) using client-side JavaScript.
We also know that, given how htmx works, the chat socket sends new messages as HTML snippets to the client.
Finally, we know that if we want to run custom JavaScript, we can use Alpine.js to do so declaratively.
So, let’s combine all this and take a look at how we must modify the Message.component so that messages are automatically decrypted as they load in the browser:
export default function Message ({ message }) {
return kitten.html`
<li
x-data='{
messageText: "${kitten.sanitise(message.cipherText)}"
}'
x-init='$nextTick(() => messageText = decryptMessage("${kitten.sanitise(message.cipherText)}", "${kitten.sanitise(message.from)}", "${kitten.sanitise(message.to)}"))'
>
<strong>${kitten.sanitise(message.from)} → ${kitten.sanitise(message.to)}</strong> <span x-text='messageText'>${kitten.sanitise(message.cipherText)}</span>
</li>
`
}
What we’re doing here is using Alpine.js’s x-data
directive to set up our data model for the list item node. In it, we populate a property called messageText
with our message’s ciphertext.
Then, we write up the x-init
directive so that when the node is initialised, we call a function called decryptMessage()
, passing it our ciphertext as well the domains that are the sender and receiver of our message.
💡 Notice that the
x-init
handler is wrapped in a call for Alpine.js’s magic$nextTick
property. This makes Alpine.js wait until DOM updates are finished so we don’t call the decryption handler before it has had a chance to load.
So what’s happening here is that the ciphertext is sent from the server to the client and, before the message is displayed, it is decrypted on the client and the plaintext is shown in the interface.
Now, let‘s actually add the encryptMessage()
and decryptMessage()
function implementations to the client-side script imported by our page.
private🔒/index.js
Our functions will make use of the built-in cryptography functions in the Kitten Cryptography API. This is a library Kitten serves at runtime from the route /🐱/library/crypto-1.js:
import { encryptMessageForDomain, decryptMessageFromDomain } from '/🐱/library/crypto-1.js'
globalThis.encryptMessage = async function (event) {
// Encrypt the plain text and send that in the message to the server.
const parameters = event.detail.parameters
const ourPrivateKey = localStorage.getItem('secret')
const encryptedMessage = await encryptMessageForDomain(parameters.plainText, ourPrivateKey, parameters.domain)
event.detail.socketWrapper.send(
JSON.stringify({
cipherText: encryptedMessage,
from: window.location.host,
to: parameters.domain
}),
event.detail.elt // elt = DOM element
)
}
globalThis.decryptMessage = async function (cipherText, fromDomain, toDomain) {
if (fromDomain === window.location.host) {
// This is a message we sent. Since the shared secret is commutative (g^jk === g^kj, or, in other words,
// can either be our private key and their public key or vice-versa), we just flip the from/to domains :)
fromDomain = toDomain
}
const ourPrivateKey = localStorage.getItem('secret')
return await decryptMessageFromDomain(cipherText, ourPrivateKey, fromDomain)
}
Encryption
Remember that the encryptMessage()
function is called by htmx before a message is sent over the socket to the server. (Specifically, when the htmx:wsConfigSend
event fires.)
Based on the htmx documentation, we know that the event
argument will contain a detail
property, which, itself, will contain a parameters
object with the form data, an elt
property that holds a reference to the DOM node that holds the socket, and a socketWrapper
that has a send()
method we can use to manually send the message after we’ve encrypted it.
So we:
-
Retrieve our secret key from local storage (this was automatically saved there for us by Kitten when we signed in.)
-
Call the
encryptMessageForDomain()
function we imported from Kitten’s cryptography library and pass it the plainText and domain from the form as well as our secret (or private key as it’s known in cryptography). -
Finally, manually create a JSON message that contains the encrypted text (cipherText) as well as the
from
andto
properties that address the sender (our domain, which we get fromwindow.location.host
so it includes our port number, which is important when testing locally) and the receiver (the remote domain, which we manually enter into the Domain: textbox in the interface).
Decryption
Similarly, remember that the decryptMessage()
function is called by Alpine.js’s x-init
directive when a new Message component is received from the server via the WebSocket. And we saw in the Message.component that we pass in the ciphertext, sender, and receiver as arguments.
So all we do here is to retrieve our secret (private key) from local storage again, check to see if we’re the sender and, if so, take advantage of the associativity property of Diffie-Hellman shared secrets to swap the receiver and sender domains thereby eventually resulting in the decryption of the message using our own public key.
To understand that last bit more fully, let’s also take a look at the encryptMessageForDomain()
and decryptMessageFromDomain()
functions in the Kitten Cryptography API.
Kitten Cryptography API
The Kitten Cryptography API (which you can find in /src/lib/crypto.js) contains, among other things, high-level functions for encrypting and decrypting messages sent between Small Web domains:
const textDecoder = new TextDecoder() // default: 'utf-8'
export async function encryptMessageForDomain (message, ourPrivateKey, domain) {
const sharedSecret = await sharedSecretForDomain(domain, ourPrivateKey)
const encryptedMessageBytes = await encrypt(sharedSecret, message)
const encryptedMessage = bytesToHex(encryptedMessageBytes)
return encryptedMessage
}
export async function decryptMessageFromDomain (encryptedMessage, ourPrivateKey, domain) {
const sharedSecret = await sharedSecretForDomain(domain, ourPrivateKey)
const decryptedMessageUInt8Array = await decrypt(sharedSecret, hexToBytes(encryptedMessage))
const decryptedMessageUtf8String = textDecoder.decode(decryptedMessageUInt8Array)
return decryptedMessageUtf8String
}
Both these methods are fairly spartan and rely on the sharedSecretForDomain()
function to carry out the heavy lifting:
// Cache shared secrets for different domains as they’re
// expensive to calculate.
const sharedSecrets = {}
async function sharedSecretForDomain (domain, ourPrivateKey) {
if (sharedSecrets[domain] === undefined) {
// We don’t have a shared secret yet. Attempt to calculate one
// by getting the other domain’s ed25519 public key.
const domainToContact = `https://${domain}/💕/id`
const theirPublicKeyResponse = await fetch(domainToContact)
const theirPublicKeyHex = await theirPublicKeyResponse.text()
const ourPrivateKeyHex = bytesToHex(emojiStringToSecret(ourPrivateKey))
sharedSecrets[domain] = await getSharedSecret(ourPrivateKeyHex, theirPublicKeyHex)
}
return sharedSecrets[domain]
}
The sharedSecretForDomain()
function is where the Diffie-Hellman key exchange happens and the shared secret that’s used to encrypt and decrypt messages between a pair of Small Web domains is calculated.
As part of the Small Web Protocol, every Small Web site serves its ed25519 public key at the /💕/id route.
💡 The Small Web Protocol requires Small Web routes to be namespaced under the 💕 path. This also happens to be the Small Web logo.
So the first thing we do is get this for the domain we want to send the message to.
Then we combine that with out private key to calculate the shared secret.
The actual calculation of the shared secret uses the getSharedSecret()
function from Paul Miller’s noble-ed25519 library.
💡 The Kitten Cryptography API also makes extensive use of Paul’s ed25519-keygen library.
Once we have the shared secret, the actual encryption and decryption are handled by the encrypt()
and decrypt()
functions from Paul’s micro-aes-gcm library, which itself uses low-level cryptographic primitives from the Web Crypto API.
💡 AES-GCM stands for Advanced Encryption Standard Galois/Counter Mode but all you really need to know is that Paul and the folks who implemented the Web Crypto API in your browser have done the hard work of implementing the cryptography and all you need to do is to use the high-level
encryptMessageForDomain()
anddecryptMessageFromDomain()
functions exposed by the Kitten Cryptography API when creating end-to-end-encrypted messages to send between peer-to-peer Small Web places.Also note that while the Kitten Cryptography API is available on both the server and the client, the encryption and decryption functions are only meant to be run in the browser. In fact, under the current Kitten runtime (Node version 18 LTS), they will cause a runtime error as the global
crypto
object that exposes the Web Cryptography API is not present. It can be easily polyfilled but isn’t on purpose as you shouldn’t be using it. When Kitten moves onto a runtime that’s version 19+, the calls won’t fail but you still shouldn’t be using them on the server.
At this point, we have made all the changes we need to on the client side but we still need to handle messages differently on the server. So, next, let’s take a look at the server-side code.
/private🔒/chat.socket.js
import Message from './Message.component.js'
if (kitten.db.messages === undefined) kitten.db.messages = [];
function messageHtml (message) {
// Wrap the same Message component we use in the page
// with a node instructing htmx to add this node to
// the end of the messages list on the page.
return kitten.html`
<div hx-swap-oob="beforeend:#messages">
<${Message} message=${message} />
</div>
`
}
// Kitten Chat example back-end.
export default function (socket, request) {
const saveAndBroadcastMessage = message => {
// Persist the message in the messages table.
kitten.db.messages.push(message)
// Since we are not optimistically showing messages
// as sent on the client, we send to all() local clients, including
// the one that sent the message. If we were optimistically
// updating the messages list, we would use the broadcast()
// method on the socket instead.
const numberOfRecipients = socket.all(messageHtml(message))
// 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.from} to ${message.to} broadcast to `
+ `${numberOfRecipients} recipient`
+ `${numberOfRecipients === 1 ? '' : 's'}.`)
}
// Handle remote messages.
const remoteMessageHandler = message => {
if (!isValidMessage(message)) {
console.warn(`Message from remote place is invalid; not saving or broadcasting to local place.`)
return
}
saveAndBroadcastMessage(message)
}
kitten.events.on('message', remoteMessageHandler)
socket.addEventListener('close', () => {
// Housekeeping: stop listening for remote message events
// when the socket is closed so we don’t end up processing
// them multiple times when the client disconnects/reconnects.
kitten.events.off('message', remoteMessageHandler)
})
socket.addEventListener('message', async event => {
// A new message has been received from a client connected to
// our own Small Web place: broadcast it to all clients
// in the same room and deliver it to the remote Small Web place
// it is being sent to after performing basic validation.
const message = JSON.parse(event.data)
if (!isValidMessage(message)) {
console.warn(`Message from local place is invalid; not saving or delivering to remote place.`)
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
saveAndBroadcastMessage(message)
// Deliver message to remote place’s inbox.
// Note: we are not doing any error handling here.
// In a real-world application, we would be and we’d be
// alerting the person if their message couldn’t be
// delivered, etc., and giving them the option to retry.
const body = JSON.stringify(message)
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
}
const inbox = `https://${message.to}/inbox`
try {
await fetch(inbox, requestOptions)
} catch (error) {
console.error(`Could not deliver message to ${message.to}`, error)
}
})
}
// Some basic validation.
// …
There are two major changes here so let’s examine them separately.
-
When we receive a message from a local client, we must not only save it in the database but also deliver it to the remote node.
-
We must also have a way of knowing when a message has been delivered to us so we can both save it in our database and broadcast it to all connected local clients.
We handle the first requirement by making a POST
call to the remote server’s /inbox route:
const body = JSON.stringify(message)
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
}
const inbox = `https://${message.to}/inbox`
try {
await fetch(inbox, requestOptions)
} catch (error) {
console.error(`Could not deliver message to ${message.to}`, error)
}
We will see what the /inbox route looks like when we implement it next.
But for now, let’s also look at how we statisfy the second requirement:
// Handle remote messages.
const remoteMessageHandler = message => {
if (!isValidMessage(message)) {
console.warn(`Message from remote place is invalid; not saving or broadcasting to local place.`)
return
}
saveAndBroadcastMessage(message)
}
kitten.events.on('message', remoteMessageHandler)
socket.addEventListener('close', () => {
// Housekeeping: stop listening for remote message events
// when the socket is closed so we don’t end up processing
// them multiple times when the client disconnects/reconnects.
kitten.events.off('message', remoteMessageHandler)
})
Here we listen tomessage
events on a global Kitten object called Events
that we haven’t seen before.
Events
is just global instance of Node’s EventEmitter
that’s made available to you for ease of authoring.
When we hear that a message has been received, we save it before broadcasting it to all local clients. And we make sure we stop listening for the event when the socket is closed so that we don’t handle messages multiple times in case clients disconnect and reconnect.
At this point, we have just one thing left to create: the /inbox route where other places can send us messages.
So let’s build that now.
/inbox.post.js
The /inbox route, as we saw earlier in the code that sends messages to it, is a POST
route. Also notice that it is not in our private directory. Any other Small Web place should be able to send a message to our inbox so it must be public.
The code for it couldn‘t be simpler:
export default function (request, response) {
const message = request.body
console.log('📥 /inbox received:', message)
kitten.events.emit('message', message)
response.end('ok')
}
All we’re doing is getting the message from the body of the request (which Kitten automatically parses from JSON for us) and then using Kitten’s convenient global EventEmitter
instance, Events
, to dispatch a message
event.
💡 Again, this is a basic example. In a real-world scenario, you would carry out further validation on received message.
Testing peer-to-peer Small Web features
To test the end-to-end encrypted Kitten chat example, you have to run more than one instance of it. So open two Terminal windows (or tabs or panes within your Terminal app) and run two instances of the example:
Terminal 1
kitten --domain=place1.localhost
Terminal 2
kitten --domain=place2.localhost --port=444
💡 Notice that you have to use not just a different domain but, when testing locally, also a different port.
Now, open https://place1.localhost/private in one browser window and https://place2.localhost:444/private in another.
In each one, enter the address of the other in the Domain: field (without the https:// prefix) and send a few messages between them.
Remember that when running in deployment from a domain name, these places would be owned by different people and reside on different computers around the world.
💡 If you want a little respite from all the cryptography stuff and give yourself a little reward, check out the Animated End-to-End Encrypted Kitten Chat project in the examples folder ;)
Deploying a Kitten application
😻 Kitten periodically and automatically checks for updates to your project on a daily basis and makes sure your deployment is up-to-date. (This is especially important if you’re deploying someone else’s app to your own server. It means you don‘t have to worry about maintaining it.) If you do want to immediately update a specific deployment, 🚧 you can do so from the /💕/update route. Also, if you want to update a specific deployment whenever you deploy to your git repository, 🚧 you can set up a special git hook to do so. Finally, you can also update Kitten itself from the CLI by running the
kitten update
command.
To deploy your app as a service, you use the deploy
command:
kitten deploy <httpsGitCloneURL>
For example, the following will create and run a production server of the Persisted Kitten Chat example, as hosted on my personal Codeberg account at my hostname.
kitten deploy https://codeberg.org/aral/persisted-kitten-chat
kitten.json
Before you deploy, you must make sure your app has a kitten.json file in its root directory and that in that file you specify the Kitten API version that your app supports and has been tested with by specifying it via the apiVersion
key.
For example:
{
"apiVersion": 1
}
Kitten productions servers periodically and automatically check for updates to both Kitten itself and to the Kitten app that is being served. Whenever there is a breaking change to the Kitten API (e.g., an existing method is removed or how it’s used is changed in such a manner that it could break existing code), the change is documented in the change log and the Kitten API version, which is a monotonically increasing integer, is incremented by one.
To see how it works, imagine that you deploy a new server running a Kitten version that has its API version set to 1. You specify this API version in your kitten.json file as shown above. Whenever Kitten checks for Kitten updates, it ensures that the API version is still 1 before updating Kitten. Say that after three Kitten updates, Kitten notices that the latest version of Kitten has an API version of 2 but that your app (which Kitten is also periodically checking for updates for from its git repository) is still at API version 1. At that point, Kitten does not install the latest version of Kitten but rather waits for you to test your app with Kitten API version 2 and specify that it is supported by updating your kitten.json file accordingly.
If there are any other Kitten packages released that support Kitten API version 1 (e.g., bug/security fixes that are backported from the latest versions of Kitten), Kitten will continue to update itself to those versions.
Once Kitten sees that you’ve updated your app to Kitten API version 2, it will go ahead and update itself to the latest package supported by that version.
So, all this to say that Kitten is smart about ensuring that its auto-updates system does not break your servers.
🪤 If you are using a database in your app, please remember that you are responsible for ensuring that schema changes do not break your app between versions. In other words, you are responsible for implementing migrations. To make this easier on yourself, you might want to ensure that the collections and individual data items you persist are custom classes that abstract away the actual objects that are persisted. That way, you can implement granular migrations in your model classes. Otherwise, you can, of course, also implement global, one-shot migrations in the traditional manner.
💡️ Production servers require systemd.
💡️ Kitten will not automatically create an alias for www for you, pass
--aliases=www.<hostname>
(or the shorthand,--aliases=www
) if you want that. You can, of course, also list any other subdomains other that www that you want Kitten to serve your site/app on in addition to the main domain.😻 If your app uses node modules, Kitten will intelligently call
npm install
on your project, as well as on any app modules your project might have.
🌲 Evergreen Web (404 → 307)
Kitten has built-in support for the Evergreen Web (404 to 307) technique.
To implement 404 to 307 redirection for your Small Web place in Kitten, simply set domain you want to forward to in your Small Web Settings page which you can reach in your browser at /💕/settings/
.
Let’s say you are deploying your new Small Web place at your own domain (e.g., https://ar.al) but you already have a personal web site or a blog there. You will already have content that other people might have linked to and/or rely on. It would be a shame for all those links to break when you deploy your new site.
The Evergreen Web (404 to 307) technique is very simple:
On your new site, issue a 307 redirect for any paths that result in a 404 (not found) error to the previous version of your site. That way, if the path existed in the previous version of your site, it will be found there and, if it did not, it will result in a 404 error there.
💡 Of course, you’re not limited to one level of indirection. If the previous version of your site replaced an even earlier version, there’s no reason why you cannot add a 404 to 307 rule there also and have a chain of redirects going all the way back to the earliest version of your site.
One of the great advantages of this technique is that you can just leave older versions of your site running (perhaps at different subdomains) and they can all continue to exist using whatever technologies they were using at the time. (You don’t have to take static exports, etc., of the data.)
By using 404 to 307, you will contribute to an evergreen web by not breaking URLs.
To see it in action, run the evergreen-web example.
For more information, see 4042307.org.
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.js | Kitten page | Compiled into HTML and served in response to a HTTP GET request for the specified path. |
.post.js, .get.js, .put.js, .head.js, .patch.js, .options.js, .connect.js, .delete.js, .trace.js | HTTP route | Served in response to an HTTP request for the specified method and path. |
.socket.js | WebSocket route | Served in response to a WebSocket request for the specified path. |
.component.js | A component file, returns HTML | Ignored by router. |
.layout.js | Layout component, returns HTML | Ignored by router. |
.fragment.js | A fragment file, returns HTML | Ignored by router. |
.script.js | Server-side script file | Ignored by router. Useful for including server-side JavaScript modules. File is not accessible from the client. |
.styles.js | Server-side styles file | Ignored by router. Useful for including server-side CSS (in JS). File is not accessible from the client. |
Other (.html, .css, .js, .jpg, .gif, etc.) | Static files | Any other files in your project apart from the ones listed above are served as static files. |
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.js 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.js route might look like this:
export default (request, response) => {
const books = kitten.db.books.get()
response.end(books)
}
URL normalisation
Kitten automatically normalises URLs that do not have a trailing slash when they should to add one.
It does so using a permanent 308 redirect that preserves the method of the request.
This means, for example, that a request to https://my.site/hello will be forwarded to https://my.site/hello/.
This is both to help implement canonical paths for your sites/apps and to avoid the unexpected situation of a relative include failing from your page if a slash is not provided in the path. (e.g., if you have an index.js file and an index.html file in a folder called hello and you include the JavaScript file using <script src='./index.js'></script>
, that will succeed if the page is hit as /hello/ but fail if it is hit as /hello.)
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.
You can get information about the database and specific tables, delete the whole database and specific tables, and tail specific tables using the command-line interface via the db info [tableName]
(or just db [tableName]
, which is a convenience alias), db delete [tableName]
and db tail <tableName>
commands.
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.js
Will create a WebSocket endpoint at:
/manage/:token/:domain
You can also intersperse path fragments with parameters:
books_[id]_pages_[page].page.js
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.js
Note that you could also have set the name of the page to index[page].page.js_. Using just [page].page.js 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.js
├ references
│ ╰ [reference].page.js
╰ images
╰ [image].page.js
You may, or may not find that easier to manage than:
my-site
├ books_[id]_pages_[page].page.js
├ books_[id]_references_[reference].page.js
╰ books_[id]_images_[image].page.js
Kitten leaves the decision up to you.
Optional parameters
To create an optional parameter, prefix your parameter name with optional-
inside the square brackets.
For example, the following route:
delete
╰ index_[subdomain]_[optional-return-page].page.js
Will match both of the following paths:
- /delete/my-lovely-subdomain/
- /delete/my-other-subdomain/troubleshooting/
The wildcard parameter
Finally, you can also use the special wildcard parameter to match any string:
my-site
╰ books_[any].page.js
The about route will match any path that begins with /books/.
💡 Under the hood, Kitten uses Polka for its routing and translates Kitten’s file system-based naming syntax to Polka’s routing syntax.
App Modules
When working on larger projects, you might end up with pages at varying levels within the site, all of which need to use the same global resource (for example, a layout template or a utility class).
In these situations, your import statements might start to get unruly.
Take the following example:
my-site
├ site.layout.js
╰ books
╰ the-handmaids-tail
╰ pages
╰ cover.page.js
Let’s say your cover.page.js uses the site layout template.
Here’s what its import statement would look like:
import Site from '../../../site.layout.js'
export default () => kitten.html`
<${Site}>
…
</>
`
That’s both cumbersome to write and error-prone. (Especially when you consider that different pages at different levels of the hierarchy may want to use the same file. You don’t want to spend your day counting dots.)
Enter App Modules.
App Modules are special local Node modules that exist only in your app (not on npm). They live in the special app_modules folder in your project and Kitten knows to ignore them when calculating its routes from the file structure of your project.
App Modules, like all Node modules, need a package.json file but they can get away with specifying a subset of the information contained in ones for npm packages. And, like all Node modules, they need to be npm installed into your project (but from a local file path instead of from npmjs.org).
Finally, if you have type checking enabled in your projects, you should add an index.d.ts file to your App Modules so your editor doesn’t complain when using the TypeScript Language Server (LSP).
Here’s how you’d make your site layout into an App Module:
-
Create the app_modules directory and then a directory for your module:
mkdir -p app_modules/site-layout
-
Add the files your module will need:
my-site ├ app_modules │ ╰ site-layout │ ├ site.layout.js │ ├ index.d.ts │ ╰ package.json ╰ books ╰ the-handmaids-tail ╰ pages ╰ cover.page.js
-
In your package.json file, you need to specify three properties:
name
,type
, andmain
:{ "name": "@app/site-layout", "type": "module", "main": "site.layout.js" }
-
Finally, if you have type checking enabled and you want to avoid type errors in your editor, your index.d.ts file should look like this:
import SiteLayout from './site.layout.js' export default SiteLayout
And that’s it.
All you need to do then is to install the App Module.
From the root of your project, install your module using its local path:
npm install ./app_modules/site-layout
🪤 You should keep App Modules as simple as possible and they should not have their own dependencies. (We’re using them to simplify authoring, not to create a monorepo.) That said, if you do end up having dependencies for your App Modules, remember to npm install them separately from within their own folders as npm will not automagically do that for you.
Finally, from cover.page.js (and any other page, anywhere in your project) you can now import your layout using:
import Site from '@app/site-layout'
That’s much better, isn’t it?
For a real world example, see the database app module in Domain that wraps Kitten’s global JSDB instance with type information and some utility methods.
Static files
You do not have to do anything special to server static files with Kitten. It is, first and foremost, a web server, after all. Any file that’s in your project folder that isn’t a hidden file or in a hidden folder and isn’t a special Kitten file (e.g., .page.js, .socket.js, .post.js, etc.), will be served as a static file.
Reserved routes
Kitten tries to be as minimally invasive into your site or app’s own namespace as possible. That said, there are certain routes that it does reserve. Some of these are part of the Small Web protocol and others are Kitten-specific.
Reserved Small Web protocol routes
The following routes are reserved as part of the Small Web protocol, which is namespaced by the Small Web emoji/logo (💕).
They are used to implement public-key authentication and manage the communication between Small Web instances and the Domain hosts that host them:
-
/💕/id
-
/💕/id/ssh
-
/💕/settings/domain (authenticated route)
-
/💕/sign-in
-
/💕/sign-out
Reserved Kitten-specific routes
Any other private Kitten-specific routes are kept in the 🐱
namespace so as not to pollute your app/site’s own URI space.
Kitten runtime libraries
Kitten comes with several runtime libraries that you can use in your sites/apps. Some of these have high-level interfaces for including them (see Page Routes) but you can include any of them in your sites/apps manually by knowning their paths:
In /🐱/library/
:
-
htmx:
htmx-1.js.gz
-
htmx WebSocket:
htmx-ws-1.js
-
Alpine.js:
hyAlpine.js.js.gz
-
prism (CSS):
prism-1.css
-
Water (CSS):
water-2.css
The -N
notation specifies the major version of the library. Since Kitten sites automatically update, if there is a major version update to a library, it will be adding alongside previous versions so as not to break existing sites. So if htmx 2.x.x comes out, for example, with breaking changes and we start supporting it, it will be added as htmx-2.js
. All minor and patch updates will be automatically updated without changing the name of the include.
Command-line interface (CLI)
💡 Use
kitten help
to access command information from your terminal.To get detailed help on a specific command, use
kitten help <command name>
. For example,kitten help serve
.You can also pass the
--help
or-h
option to any command to get more detailed information about it. Sokitten serve --help
is equivalent tokitten help serve
.
Default command
serve
kitten [path to serve] [options]
kitten serve [path to serve] [options]
Serves a Kitten place.
💡 If do not specify a path to serve, the default directory (./) is assumed.
Options
--domain
: Main domain name to serve site at. Defaults to hostname in production, localhost during development.--aliases
: Additional domains to respond to (each gets a TLS cert and 302 redirects to main domain). e.g., www.--port
: The port to create the serer on. Defaults to 443.--working-directory
: The working directory Kitten was launched from. Defaults to current directory (default .)
💡 When running multiple Kitten instances on different ports to test peer-to-peer functionality during development, remember to use different domains for each instance (for example,
localhost
for one and127.0.0.1:444
for the other) to ensure that sessions work correctly. This is because Kitten sessions make sure of cookies and cookies do not provide isolation by port. There is currently an issue open on Auto Encrypt Localhost to add IPs 127.0.0.2 - 127.0.0.4 to generated certificates so you can test up to four peers via the 127.0.0.1 - 127.0.0.4 loopback addresses (five, if you add localhost). If you have a need for more – perhaps for some sort of automated testing – please open an issue here, explain your use case, and we’ll consider adding more.
Database commands
db (alias for db info)
kitten db [table name]
kitten db info [table name]
Shows database and table information.
db delete
kitten db delete [table name]
Deletes either the whole database or the table with the specified table name after asking you for confirmation.
db tail
kitten db tail <table name>
Shows a summary of the head (start) and tail (end) of your database table (remember that database tables in JSDB are append-only JavaScript logs) and starts to follow additions to it.
Press Ctrl C to exit as you would a regular shell tail
command.
Deployment of Kitten applications
deploy
kitten deploy <git HTTPS clone URL> [options]
Creates a Kitten place by deploying a Kitten project from a git repository as a systemd service.
Options
--domain
: Main domain name to serve site at. Defaults to hostname in production, localhost during development.--aliases
: Additional domains to respond to (each gets a TLS cert and 302 redirects to main domain). e.g., www.
Kitten daemon (systemd service) commands
status
kitten status
Shows Kitten systemd service status.
logs
kitten logs [options]
Tails (follows) the Kitten systemd service logs using journald.
Options
--since
: Time span to show logs for. For example,6 hours ago
,yesterday
, etc. (defaults to1 hour ago
).
start
kitten start
Starts the Kitten systemd service.
stop
kitten stop
Stops the Kitten systemd service.
enable
kitten enable
Enables the Kitten systemd service (so it auto starts on boot and if the process exits)
disable
kitten disable
Disables the Kitten systemd service (so it no longer auto starts on boot, etc.)
General commands
version (aliases: --version and -v)
kitten version
kitten --version
kitten -v
Displays the Kitten version, made up of:
- Date of the build (the Kitten’s birthday)
- The git commit hash of the build (as represented by the RGB colour equivalent of the hex value; the Kitten’s favourite colour)
- The API version (the major semver component from package.json.)
🚧 Eventually, you will be able to tell Kitten what API version your app is written for and it will only auto-update to the latest release in that API version.
help (aliases: --help and -h)
kitten help
kitten help <command name>
kitten <command name> --help
kitten <command name> -h
Use kitten help
to access command information from your terminal.
To get detailed help on a specific command, use kitten help <command name>
. For example, kitten help serve
.
You can also pass the --help
or -h
option to any command to get more detailed information about it. So kitten serve --help
is equivalent to kitten help serve
.
update
kitten update
kitten update <API version>
Use kitten update
to update (upgrade or downgrade) your currently installed version of Kitten.
When you run the command without supplying the optional API version
argument, Kitten will attempt to update to the more recent publicly-available Kitten package from kittens.small-web.org. If your version of Kitten is more recent than the most recent publicly-available Kitten package, Kitten will prompt you to ask if you want to downgrade your installation.
If you supply the optional API version
argument, the update will check for the latest version for the given API version. If such an API version does not exist, you will get an error. If your version of Kitten is already on that API version but is a more recent build, you will be prompted whether you want to downgrade your installation.
💡 The only time the downgrade prompt should show is if you’re running a local development build that is more recent that the most recent publicly-available Kitten package. This is useful if you want to quickly test a publicly-available package during development. You can, of course, always install the latest development version by running the
./install
script as usual.
uninstall
kitten uninstall
Uninstalls Kittens and removes all Kitten data after asking for confirmation.
Install Kitten on Windows under WSL 2
Kitten runs on Windows under WSL with the following caveats:
- Requires WSL 2 (will not work with WSL 1).
- You must manually install Kitten’s local development-time certificate authority in your Windows browsers.
If you haven’t installed Kitten on Windows or used WSL 2 before, the instructions, below, take you through the whole process.
Instructions
-
Open up a Windows Powershell tab in Terminal App.
🪤 Windows comes with a couple of terminal apps and shells. Make sure you use the exact pair mentioned above.
-
Install WSL 2:
wsl --install
This will install WSL 2 and Ubuntu. It will ask you to choose an account name and set a password and then you will be running Ubuntu under WSL2.
Kitten only works with WSL 2 using Windows Terminal and Windows Powershell (the
sysctl
command used by the installer fails on systems with WSL 1).To make sure your Linux container is running under WSL 2 (and not 1) before continuing, run:
uname -a
On my system, that gives me the following system information:
Linux aral-win11 5.15.90.1-microsoft-standard-WSL2 #1 SMP Fri Jan 27 02:56:13 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux
Note that the string
WSL2
is contained in the output. If you don’t see that, you’ll have to either create a new container using WSL 2 or update your current container to use WSL 2.💡 For best results, please make sure you’re running the latest Ubuntu version that’s installed automatically by wsl when you run the
wsl --install
command.(If you’re running a very old version of Ubuntu, it may not have the new Let’s Encrypt certificate authority in its trust store and the Kitten installer may fail with a certificate error. If this happens, please either upgrade your version of Ubuntu or try tunning
sudo dpkg-reconfigure ca-certificates
to download the latest list of trusted certificate authorities into the system store.) -
Install Kitten:
wget -qO- https://codeberg.org/kitten/app/raw/install | bash
(Enter the password you set up for your Ubuntu installation under WSL 2 when asked.)
-
Source your .profile to add Kitten’s binary to your path:
source .profile
(Kitten is now installed properly but Ubuntu doesn’t add the ~/.local/bin folder that Kitten puts its binary into to your system path until that path exists on your computer. Instead of sourcing the .profile, you can also log out and log back in.)
-
Test the Kitten binary.
kitten --version
You should see Kitten launch and the version get displayed.
💡If Kitten fails to launch, please open an issue and try to give as much information about what went wrong as you can. Copy and paste any error messages you see. Also run
uname -a
andlsb_release --all
and paste the output from those commands into your issue. -
Create your first project and run Kitten.
❣️ Don’t skip this step! The first time Kitten runs, it creates your local development-time certificate authority (CA) and TLS certificate. You will need the CA to install in your Windows browsers in the next section.
mkdir hello-kitten cd hello-kitten echo 'Hello, Kitten!' > index.html kitten
Kitten should launch and start serving your site.
However, if you open a web browser under Windows (e.g., the default Edge browser), you will see a
NET::ERR_CERT_AUTHORITY_INVALID
security error warning you that your connection isn't private.This is because Kitten is running under Ubuntu and your browser is running under Windows. While Kitten can (and does) update your Linux system trust store to accept its local development certificates, it cannot do that for your Windows machine because it doesn’t even know that it exists.
So, on Windows, you have to manually install Kitten’s certificate authority in your Windows browsers.
The next section takes you through doing that.
Manually install the certificate authority in your Windows browsers.
For browsers you have installed under Windows to accept the TLS certificates created by Kitten, you must install its certificate authority into your browsers. (Remember that Kitten is running under Linux and doesn’t know anything about your Windows environment so it can’t do it for you.)
For example, if you’re using Microsoft Edge:
-
Go to
edge://settings/privacy
, scroll down to the Security section, and select “Manage certificates.” -
In the resulting “Certificates” modal, select the Trusted Root Certificate Authorities tab and press the Import… button.
-
In the resulting Certificate Import Wizard, press Next to pass from the welcome screen to the File to Import step and press the Browse… button.
-
Make sure the file extensions drop-down is set to show all extensions and select Linux → Ubuntu → home → (your home directory) → .local → share → small-tech.org → kitten → tls → local → auto-encrypt-localhost-CA.pem.
-
In the next step, make sure “Place all certificates in the following store” is selected with “Trusted Root Certification Authorities” set.
-
Press Next and, on the final screen, press Finish.
-
After the wizard closes, you will see a Security Warning telling you that you are about to install a root certificate. Select Yes to continue.
Finally, restart Edge and you should be able to hit https://localhost without certificate errors.
Now that you’ve successfully installed Kitten on Windows under WSL 2, you can continue learning about Kitten by following the Getting Started guide and tutorials.
Troubleshooting
Here are some edge cases you might encounter (because others have encountered them) and what you can do about it:
Linux: Untrusted localhost TLS certificates in Chrom(ium)
If at some point Chrom(ium) starts complaining that your development-time localhost certificates are untrusted under Linux, check that you haven’t accidentally installed a copy of ca-certificates
and/or p11-kit
in your unprivileged account using a third-party package manager like Homebrew (brew
).
If this is the issue, you should:
- Remove the
ca-certificates
andp11-kit
packages from Homebrew (you will also have to remove any packages that depend on them. e.g., python, OpenSSL, etc.) - Restart your machine.
- Run
sudo update-ca-trust extract
- Uninstall Kitten (
kitten uninstall
) - Reinstall Kitten from here or from your working copy of the source code (
./install
)
That should do it.
To check if your system trust store is set up correctly, you can run the following command:
trust list --filter=ca-anchors | grep Localhost
And you should see output that resembles the following:
label: Localhost Certificate Authority for aral at dev.ar.al
Building Kitten
While developing Kitten, it’s best practice to run the install
script and use the kitten
command to run your installed build.
./install
A typical run of the install commands takes about half a second on a modern computer so it should not impact your development velocity negatively.
💡 In order to keep the development build/install process as quick as possible, dependencies are not updated unless you specifically request an
npm install
by passing the--npm
flag:./install --npm
(However, an npm install will be carried out if this is the first time you’re building/installing Kitten locally.)
There is a separate build
command (called internally by the install script) and if you use that, you will find the distribution under the dist/ folder.
To run Kitten from the distribution folder, use the following syntax:
dist/kitten [path to serve]
💡 Kitten’s build + install process (what happens when you run the install script) takes less than half a second on a modern computer and has the additional benefit of informing you of compile-time errors. It’s highly recommended you don’t run build by itself or run from the dist folder directly unless you have specific reason to.
Debugging
To run Kitten with the Node debugger active (equivalent of launching Node.js using node --inspect
), start Kitten using:
INSPECT=true kitten
💡 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.
💡 In Chromium, you can use the Node debugger (enter
chrome://inspect
in address bar → select ‘Open dedicated DevTools for Node’).
Profiling
You can see profiling information provided by Kitten’s console
mixin methods profileTime()
and profileTimeEnd()
by passing the PROFILE=true
environment variable:
e.g.,
PROFILE=true kitten examples/streamiverse
Flame Graphs
To narrow down performance issues by time spent on the stack, you can have Kitten generate a flame graph by setting the FLAME_GRAPH
environment variable to true
.
e.g.,
FLAME_GRAPH=true kitten examples/streamiverse
This will start your project in production mode and globally install and use the 0x node module to capture profiling information and automatically launch a browser when the process exits to show you the flame graph.
💡 Flame graphs can only be generated in production mode as the 0x module is not compatible with Node’s cluster module and the latter is what we use to implement Kitten’s process manager in development mode.
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.
🚧 Tests are in the process of being ported from NodeKit to Kitten.
Deployment (of Kitten itself)
To build and deploy a new Kitten package to kittens.small-web.org, run the deploy script:
./deploy
💡You will need to add the secret deployment token to your system in order to deploy. If you have deployment rights for Kitten, follow the instructions available at https://kittens.small-web.org/settings.
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. |
xhtm | XHTM is alternative implementation of HTM without HTM-specific limitations. Low-level machinery is rejected in favor of readability and better HTML support. |
hyperscript-to-html-string | Render xhtm/Hyperscript to HTML strings, without virtual DOM. |
sanitize-html | Clean up untrusted HTML, preserving whitelisted elements and whitelisted attributes on a per-element basis. Built on htmlparser2 for speed and tolerance |
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. |
noble-ed25519 | Cryptography: ed25519 key generation. |
ed25519-keygen | Cryptography: SSH key generation from ed25519 key material. |
Kitten’s renderer is built upon xhtm and vhtml although both of those libraries have been inlined and extended.
For an automatically-generated full list of modules and contributors, please see CONTRIBUTORS.md.
Cryptographical properties
(The functionality described in this section is currently being developed both in Domain and Kitten.)
Overview
The security (and privacy) of Domain/Kitten are based on a 32-byte cryptographically random secret string that only the person who owns/controls a domain knows.
(This is basically an ed25519 secret key.)
When setting up a Small Web app via Domain, this key is generated in the person’s browser, on their own computer, and is never communicated to either the Domain instance or the Kitten app being installed. Instead the ed25519 public key is sent to both and signed token authentication is used when the server needs to verify the owner’s identity (e.g., before allowing access to the administration area).
The expected/encouraged behaviour is for the person to store this secret in their password manager of choice.
From this key material, we derive SSH keys and the person’s server is set up to allow SSH access via this key.
Kitten running on a development machine can also recreate these SSH keys and configure the person’s SSH access to their server when the secret key is provided.
The person’s ed25519 public key is used as their identity within the system and enables their Small Web place (Kitten app) to communicate with the Domain instance for administrative reasons (e.g., to cancel hosting, etc.)
Threat model
The audience for Domain/Kitten and the Small Web in general is everyday people who use technology as an everyday thing and want privacy by default in the systems they use.
If you are an activist, etc., who might be specifically targetted by nation states, etc., please do not use this system as it does not protect against, for example, infiltration of hosting providers by nation state actors.
Given that the secret key material is generated in a web app, you must trust that the code being served by the server is what you expect it to be. Currently, there is no validation mechanism for this, although this is on the roadmap going forward (via, for example, a browser extension). This is not a problem unique to web apps, given that native apps are commonly dynamically built by app stores (at which point a nation state actor could inject malicious code) and given the lack of verifiable builds in mainstream supply chains.
You can, however, overcome this limitation by hosting Domain yourself, on your own hardware (e.g., a single-board computer like a Raspberry Pi).
The other thing to be aware of is that the security of the system is based on your secret key remaining secret and, initially at least, there will not be a way to change this secret key. (On the longer roadmap, it would be nice to provide a means of changing it but this is not a trivial process, especially if encryption keys have been derived from it and encrypted messages exist within a Kitten app).
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’ll bend over backwards to cater to all your Big Web needs.
Can you add <insert Big Tech feature here>?
No.
Will this scale?
*le sigh!*
(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… you know the whole surveillance capitalism and people farming thing?)
No, there is nothing in the license to stop you from doing so.
But I will haunt you in your nightmares if you do.
(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.)
Questions?
Contact Aral on the fediverse.