A web development kit that’s small, purrs, and loves you.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
Aral Balkan 8dc9eb1772 Update distribution build 2 days ago
bin Remove the dev-time Kitten script 1 month ago
build-templates Fixes #29: Kitten commands now exit properly 2 weeks ago
dist Update distribution build 2 days ago
examples Document new is() and isNot() conditionals and add an example 2 weeks ago
src Add app-specific password generation 2 days ago
tests Fix test fixture creation 6 days ago
.gitignore Specifically DO NOT ignore the node_modules folder in dist/ 1 month ago
.nvmrc Update .nvmrc to latest version supported 1 month ago
.woodpecker.yml Add Woodpecker continuous integration (CI) configuration 1 week ago
LICENSE Add AGPL v3 license text 2 weeks ago
README.md Add app-specific password generation 2 days ago
build Move Routes/index.js to Routes.js; rename Routes/ to routes/ 1 week ago
install Make sure unprivileged binary folder exists before symlinking from there 1 week ago
package-lock.json Update JSDB to version 2.0.7 3 weeks ago
package.json Progress #30: Reintroduce loader tests 1 week ago
suppress-experimental.cjs Remove undici now fetch API is in Node; bump minimum Node version to 18+ 4 months ago

README.md

😸 Kitten

A web development kit that’s small, purrs, and loves you.

Create your site using plain HTML, CSS, and JavaScript then enhance it with htmx and hyperscript, if you like.

🍼 Warning: Kitten is still a baby.

Sections marked with 🚧 represent features that are not implemented yet.

Please feel free to have a play but proceed at your own risk. Here be dragons kittens.

System requirements

  • Linux
  • git
  • tar
  • tee
  • xz

For production servers:

  • systemd

Install

Here are two ways you can install Kitten:

😇 Clone this repository and run the ./install script.

😈 Or, if you enjoy living dangerously, copy and paste one of the following commands into your terminal and run it (ooh, naughty!)

🤓 View install script source to verify it’s safe to pipe to your shell.

Using wget:

wget -qO- https://codeberg.org/kitten/app/raw/install | bash

Using curl:

curl -sL https://codeberg.org/kitten/app/raw/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).

Getting started

You can run Kitten using the following syntax:

kitten [path to serve]

💡 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.

Deployment

To deploy your app as a service, 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

💡 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 during the deployment process.

😻 🚧 Kitten 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 your deployment whenever you deploy to your git repository, 🚧 you can set up a special git hook to do so.

Examples

The best way to get started is to work through the tutorials and play with the examples:

e.g., to launch the Kitten Chat example, run:

kitten examples/kitten-chat

💡 Remember to run npm install on any examples that have a package.json file in them.

🚧 Kitten will automatically do this for you in the future.

Tutorials

Hello, world!

Let’s quickly create and test your first “Hello, world!” Kitten site.

Create a file called index.html and add the following content to it:

<h1>Kitten</h1>
<p>😸</p>

Now run kitten, hit https://localhost, and you should see your new site.

Yes, Kitten will happily serve any HTML you throw at it just like any good web server should.

But you can render HTML using any old web server…

So 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 and update its contents to match the following:

let count = 1

export default _ => (
  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.

Kitten uses file system routing. So, in this example, your /index.page file is mapped to the root route (/) of your site.

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 html tagged template literals. These are just standard JavaScript template literals (template strings) that get passed through a special global function called html.

🪤 Make sure you close all your HTML tags! If you don’t, Kitten will throw a tantrum.

💡 Under the hood, the html function uses htm and vhtml to do its magic.

💡 As we’ll see in the later examples, you can also return an object that has separate markup, styles, and script properties. And you can keep styles and scripts in external files that use the .styles and .script extensions that you import into your pages.

The example above contains the minimum code required to achieve our goal. However, here’s a more explicit version in case you’re not familiar with JavaScript arrow functions so you can really see what’s happening (and to underscore – no pun intended – that there’s really no magic involved).

let count = 1

export default function route (_request, _response) {
  return 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 file is a route and that it gets passed the HTTP request and response objects.

Underscores?

We’ve added underscores to the names of the request and response parameters to make it clear that we know we’re not using them in the body of the function. We could also just have left the parameters out but it’s always good to be as explicit as possible about your intend when coding.

Persistence is the secret to success (or something)

So counting kittens is great fun but what happens if you restart the server?

All your kittens are lost! (This is a tragedy.)

So let’s fix that.

(Brace yourself, you’re about to use – drumroll – a SCARY database! Oooh!)

👻 Using JavaScript Database (JSDB) – a (not so) scary database

Update your code to match this:

if (db.kittens === undefined) db.kittens = {count: 1}

export default _ => (
  html`
    <h1>Kitten count<h1>
    <p>${'😸'.repeat(db.kittens.count++)}</p>
  `
)

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 ~/.local/share/small-tech.org/kitten/databases folder and you should see a folder there that mirrors the path of your project (e.g., if your project is in /var/home/aral/projects/greetings, the folder will be var.home.aral.projects.greetings). Kitten also helpfully creates a symbolic link to the database folder of your latest run project at ~/.local/share/small-tech.org/kitten/database. Inside your project’s database folder, you should see a kittens.js file. Open it up in a text editor and take a look. You should see something like this:

export const _ = { 'count': 18 };
_['count'] = 19;
_['count'] = 20;

That’s what a table looks like in JavaScript Database (JSDB), which is integrated into Kitten and available to all your routes via a global db reference.

Yes, you need never fear persistence ever again.

There’s so much more to JSDB that you can learn about in the JSDB documentation.

💡 Initialising things

If initialising your database table in your route feels a little yucky, it’t 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 to be safe. You can see why this could become a maintenance nightmare.

Kitten to the rescue: if you create a special script called main.script in the root of your folder, Kitten will import the default route on this and run it at start up. This is a great place to carry out global initialisation for your app. This method also gets passed a reference to the Polka app instance so you can also 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 like this:

export default function (_app) {
 if (db.kittens === undefined) db.kittens = {count: 1}
}

Which would leave our route looking like the following:

export default _ => (
  html`
    <h1>Kitten count<h1>
    <p>${'😸'.repeat(db.kittens.count++)}</p>
  `
)

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.

🚧 This section is about to be rewritten as the is().yes().no().endIs() construct is being removed from Kitten. Please use the JavaScript ternary operator and && and || instead.

if (db.kittens === undefined) db.kittens = {count: 1}

export default _ => {
  db.kittens.count++

  return html`
    <h1>Kitten count</h1>
    <p>${'😸'.repeat(db.kittens.count > 20 ? 20 : db.kittens.count)}</p>
    ${db.kittens.count > 20 ? html`<p>(and ${db.kittens.count - 20} more.)</p>` : ''}
  `
}

Now we’re using the JavaScript ternary operator in two places two limit the number of kittens displayed to 20 and, if we’ve reached this limit, to display the overflow in text.

So this is one way to include conditional logic in your HTML templates.

However, the ternary operator (condition ? true : false) isn’t the easiest thing in the world to read. Especially if the branches (true/false) contain complex markup themselves. That’s why Kitten also provides two global functions you can use called is() and isNot() to provide some syntactic sugar for conditionals.

Here’s what the above example looks like using the is() function for the second conditional:

if (db.kittens === undefined) db.kittens = {count: 1}

export default _ => {
  db.kittens.count++

  return html`
    <h1>Kitten count</h1>
    <p>${'😸'.repeat(db.kittens.count > 20 ? 20 : db.kittens.count)}</p>
    ${
      is(db.kittens.count > 20)
        .yes(html`<p>(and <strong>${db.kittens.count - 20}</strong> more.)</p>`)
        .no(html`<small>Keep refreshing, we need more kittens!</small>`)
      .endIs()
    }
  `
}

And you’re not limited to using the is() or isNot() functions in HTML templates either. Let’s replace the first conditional with is() too:

if (db.kittens === undefined) db.kittens = {count: 1}

export default _ => {
  db.kittens.count++

  return html`
    <h1>Kitten count</h1>
    <p>${'😸'.repeat(is(db.kittens.count > 20).yes(20).no(db.kittens.count).endIs())}</p>
    ${
      is(db.kittens.count > 20)
        .yes(html`<p>(and <strong>${db.kittens.count - 20}</strong> more.)</p>`)
        .no(html`<small>Keep refreshing, we need more kittens!</small>`)
      .endIs()
    }
  `
}

Fetchiverse

You can install, import, and use Node modules in your project just like in any other Node.js project. However, Kitten also has commonly-used global APIs you can use without installing or importing them. You’ve already seen one of those, the JavaScript Database (JSDB), which is available via the global db reference.

Similarly, the Fetch API is available for use as fetch.

Here’s an example of how to use the Fetch API to get the list of public posts from a Mastodon instance.

This is the instance we’ll be using: https://mastodon.ar.al

And this is the JSON endpoint with the public timeline data: https://mastodon.ar.al/api/v1/timelines/public

Take a look at both to understand what we’re working with before creating a new folder called fetchiverse with a file called index.page in it. Then add the following code to that file:

export default async function route (_request, _response) {
  const postsResponse = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
  const posts = await postsResponse.json()
  
  const markup = html`
    <h1>Aral’s Public Fediverse Timeline</h1>
    <ul>
      ${posts.map(post => (
        html`
          <li>
            <a class='avatar-link' href='${post.account.url}'>
              <img class='avatar' src='${post.account.avatar}' alt='${post.account.username}’s avatar'>
            </a>
            <div class='content'>
              ${post.content}
              ${post.media_attachments.map(media => (
                media.type === 'image' ? html`<img class='image' src='${media.url}'>` : ''
              ))}
            </div>
          </li>
        `
      ))}
    </ul>
  `

  return { markup, styles }
}

// Notice we’re creating the styles outside of the route
// for efficiency as they are static.

const styles = css`
  body { font-family: sans-serif; font-size: 1.25em; padding-left: 1.5em; padding-right: 1.5em; }
  h1 { font-size: 2.5em; text-align: center; }
  p:first-of-type { margin-top: 0; }
  p { line-height: 1.5; }
  a:not(.avatar-link) {
    text-decoration: none; background-color: rgb(139, 218, 255);
    border-radius: 0.25em; padding: 0.25em; color: black;
  }
  ul { padding: 0; }
  li {
    display: flex; align-items: flex-start; column-gap: 1em; padding: 1em;
    margin-bottom: 1em; background-color: #ccc; border-radius: 1em;
  }
  .avatar { width: 8em; border-radius: 1em; }
  .content { flex: 1; }
  .image { max-width: 100%; }
`

Now, run kitten with:

kitten make-fetch-happen

And hit https://localhost to see the latest public timeline from Aral’s mastodon instance.

💾 This example is available in examples/fetchiverse.

💾 There’s also version of this example that implements a streaming timeline using a WebSocket in examples/streamiverse.

Components and fragments

The above example is only about 50 lines of code in a single file. While that’s fine for something so simple, in larger examples, it would help us maintain our code if we break it up into smaller components and fragments.

Let’s start by examining the layout of our list. We have two major elements in each list item: the author’s avatar and the post content itself. These would be prime candidates to make into separate fragments or components. So let’s do that:

export default async function route (_request, _response) {
  const postsResponse = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
  const posts = await postResponse.json()
  
  const markup = html`
    <h1>Aral’s Public Fediverse Timeline</h1>
    <ul>
      ${posts.map(post => (
        html`
          <li>
            <${Avatar} post=${post} />
            <${Content} post=${post} />
          </li>
        `
      ))}
    </ul>
  `
  
  return { markup, styles }
}

const Avatar = post => (
  html`
    <a class='avatar-link' href='${post.account.url}'>
      <img class='avatar' src='${post.account.avatar}' alt='${post.account.username}’s avatar'>
    </a>
  `
)

const Content = post => (
  html`
    <div class='content'>
      ${post.content}
      ${post.media_attachments.map(media => (
        media.type === 'image' ? html`<img class='image' src='${media.url}'>` : ''
      ))}
    </div>
  `
)

const styles = css`
  body { font-family: sans-serif; font-size: 1.25em; padding-left: 1.5em; padding-right: 1.5em; }
  h1 { font-size: 2.5em; text-align: center; }
  p:first-of-type { margin-top: 0; }
  p { line-height: 1.5; }
  a:not(.avatar-link) {
    text-decoration: none; background-color: rgb(139, 218, 255);
    border-radius: 0.25em; padding: 0.25em; color: black;
  }
  ul { padding: 0; }
  li {
    display: flex; align-items: flex-start; column-gap: 1em; padding: 1em;
    margin-bottom: 1em; background-color: #ccc; border-radius: 1em;
  }
  .avatar { width: 8em; border-radius: 1em; }
  .content { flex: 1; }
  .image { max-width: 100%; }
`

Once you’ve separated your page into components and fragments, there’s no rule that says they must all be in the same file. Since they are just snippets of JavaScript, you can put each one in its own file and import it in.

😻 In Kitten, we call a custom HTML element that can be included as a custom tag a component and any other snippet of HTML, CSS, or JavaScript a fragment. They go in .component and .fragment files, respectively. Like every other Kitten-specific extension, these are just JavaScript files. And you don’t have to adhere to this convention but others who know Kitten can contribute to your projects more easily if you do.

So here’s one way we could organise the code:

index.page

import Avatar from './Avatar.component'
import Content from './Content.component'
import styles from './styles.fragment'

export default async function route (_request, _response) {
  const postsResponse = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
  const posts = await postsResponse.json()

  const markup = html`
    <h1>Aral’s Public Fediverse Timeline</h1>
    <ul>
      ${posts.map(post => (
        html`
          <li>
            <${Avatar} post=${post} />
            <${Content} post=${post} />
          </li>
        `
      ))}
    </ul>
  `
  
  return { markup, styles }
}

Avatar.component

export default ({ post }) => (
  html`
    <a class='avatar-link' href='${post.account.url}'>
      <img class='avatar' src='${post.account.avatar}' alt='${post.account.username}’s avatar' />
    </a>
  `
)

Content.component

export default post => (
  html`
    <div class='content'>
      ${post.content}
      ${post.media_attachments.map(media => (
        media.type === 'image' ? html`<img class='image' src='${media.url}'>` : ''
      ))}
    </div>
  `
)

styles.fragment

export default _ => (
  css`
    body { font-family: sans-serif; font-size: 1.25em; padding-left: 1.5em; padding-right: 1.5em; }
    h1 { font-size: 2.5em; text-align: center; }
    p:first-of-type { margin-top: 0; }
    p { line-height: 1.5; }
    a:not(.avatar-link) {
      text-decoration: none; background-color: rgb(139, 218, 255);
      border-radius: 0.25em; padding: 0.25em; color: black;
    }
    ul { padding: 0; }
    li {
      display: flex; align-items: flex-start; column-gap: 1em; padding: 1em;
      margin-bottom: 1em; background-color: #ccc; border-radius: 1em;
    }
    .avatar { width: 8em; border-radius: 1em; }
    .content { flex: 1; }
    .image { max-width: 100%; }
  `
)

💡 Separating your pages into components and fragments should make your sites easier to maintain but this does come at the expense of locality of behaviour (keeping related functionality together so you can easily read through the code in a linear fashion). In this example, I probably would have kept the Avatar and Content components in the index.page file and put the CSS fragment into its own file. Don’t be afraid to experiment with how you organise your own projects. Soon, you’ll develop a knack for knowing when you’ve hit the sweet spot.

Streamiverse

While the fetching a fediverse timeline is fun and all, wouldn’t it be cooler if you could stream it? Let’s do just that using a WebSocket on the server and htmx to enhance base fetchiverse example.

index.page

Let’s start with the code for our new index page:

import styles from './styles.fragment'
import Post from './Post.component'

export default async function route (_request, _response) {
  const response = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
  const posts = await response.json()
  
  const markup = html`
    <h1>Aral’s Public Fediverse Timeline</h1>
    <ul id='posts' hx-ext='ws' ws-connect='/updates'>
      ${posts.map(post => html`<${Post} post=${post} />`)}
    </ul>
  `

  return({ markup, styles, uses: [HTMX, HTMX_WEBSOCKET] })
}

Pay special attention to the unordered list tag’s attributes:

<ul id='posts' hx-ext='ws' ws-connect='/updates'>
  ${posts.map(post => html`<${Post} post=${post} />`)}
</ul>

Specifically:

  • It has an id. We will be using this id 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 (ws-connect='/updates').

Finally, we have to tell Kitten, explicitly, that we are using htmx and its WebSocket extension so it knows to include script tags in the head of the rendered page to load in those libraries:

return({ markup, styles, uses: [HTMX, HTMX_WEBSOCKET] })

💡 Kitten has built-in support for htmx, the htmx WebSockets extension, and hyperscript. It exposes the HTMX, HTMX_WEBSOCKET, and HYPERSCRIPT constants for you globally to use when returning a uses 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 html`
    <li>
      <${Avatar} post=${post} />
      <${Content} post=${post} />
    </li>
  `
}

const Avatar = ({ post }) => (
  html`
    <a class='avatar-link' href='${post.account.url}'>
      <img class='avatar' src='${post.account.avatar}' alt='${post.account.username}’s avatar' />
    </a>
  `
)

const Content = ({ post }) => (
  html`
    <div class='content'>
      ${raw(post.content)}
      ${post.media_attachments.map(media => (
        media.type === 'image' ? html`<img class='image' src='${media.url}' />` : ''
      ))}
    </div>
  `
)

A post is a natural unit for a component in our example as we receive individual post updates from the Mastodon API. Since we send HTML over the wire, our web socket will have to create Post instances. And we also have to create Post instances in our original GET route in the index page that sends over the initial timeline. By having Post as a component in its own module, we can import and use it from both places.

💡 None of the code in the Post, Avatar, or Content 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 files.

import Post from './Post.component'

let stream = null

export default function socket (socket, _request) {
  // Lazily start listening for timeline updates on first request.
  if (stream === null) {
    console.log('  🐘 Listening for Mastodon updates…')
    stream = new WebSocket('wss://mastodon.ar.al/api/v1/streaming?stream=public')

    stream.addEventListener('message', event => {
      const message = JSON.parse(event.data)
      if (message.event === 'update') {
        const post = JSON.parse(message.payload)

        console.log(`  🐘 Got an update from ${post.account.username}!`)

        const update = html`
          <div hx-swap-oob="afterbegin:#posts">
            <${Post} post=${post} />
          </div>
        `

        socket.all(update)
      }
    })
  }
}

The WebSocket route itself creates a WebSocket connection to consume Aral’s public Mastodon feed. It also adds a message listener that gets called each time there’s a message from the Mastodon server. Since we only care about new posts in this example, we only handle update messages.

The messages sent by Mastodon are in JavaScript Object Notation (JSON) format so the first thing we do is to parse them into a plain JavaScript object. And in that object we find the payload property that contains the post itself.

Now comes the the htmx magic:

const update = html`
  <div hx-swap-oob="afterbegin:#posts">
    <${Post} post=${post} />
  </div>
`

The hx-swap-oob attribute (which stands for swap out-of-band) will signal to htmx in the browser that it should add this post to the top of the list of posts on the page (remember that our posts list had the id of posts).

Finally, after we’ve created our Post snippet, we send it to all connected WebSocket clients using the special .all() method on the socket object:

socket.all(update)

And that’s all there is to it!

Run the example using kitten and visit https://localhost to see Aral’s public fediverse timeline streaming from his Mastodon server.

💡 Notice that we’re sending HTML over the wire using the WebSocket. This is how htmx works. It makes it possible for us to create dynamic functionality like a streaming fediverse timeline without writing any custom client-side JavaScript.

Kitten chat

In the Streamiverse example, we used a WebSocket to stream in post updates but we could just as well have used Server-Sent Events (SSE) given that the communication was one-way.

💡 Kitten does not currently implement support for HTTP/2 so if you use SSE, people will be limited to 6 browser connections to your app.

🚧 Support for Server-Sent Events has not been implemented yet in Kitten.

What’s unique about WebSockets is that you can carry out asynchronous two-way communication. So let’s take advantage of that by creating a very simple chat app.

To being with, let’s create the index page:

index.page

import styles from './index.styles'

// The page template is static so we render it outside
// the route handler for efficiency (so it only gets rendered
// once when this file first loads instead of on every request).
const markup = html`
  <h1>😸 <a href='https://kitten.small-web.org'>Kitten</a> Chat</h1>

  <div id='chat' hx-ext='ws' ws-connect='/chat'>
    <ul id='messages'>
       <!-- Received messages will go here. -->
    </ul>

    <form id='message-form' ws-send>
      <label for='nickname'>Nickname:</label>
      <input id='nickname' name='nickname' required />
      <label for='text'>Message:</label>
      <input id='text' name='text' required />
      <button id='sendButton' type='submit'>Send</button>
    </form>
  </div>
`

export default function route (_request, _response) {
  return { markup, styles, uses: [HTMX, HTMX_WEBSOCKET] }
}

Also, create an index.styles file and add the following CSS rules to it so our interface fills up the whole browser window, with the majority of the space reserved for the chat messages.

index.styles

export default _ => css`
  * { box-sizing: border-box; }

  h1 {
    margin-top: 0;
    margin-bottom: 0;
  }

  p {
    margin-top: 0;
    margin-bottom: 0;
  }

  a {
    color: #334b4c;
  }

  form {
    background: #eee;
    display: grid;
    grid-template-columns: [labels] auto [controls] 1fr;
    align-items: center;
    grid-row-gap: 0.5em;
    grid-column-gap: 0.5em;
    padding: 0.75em;
    width: 100%;
  }

  form > label { grid-column: labels; }

  form > input, form > button {
    grid-column: controls;
    min-width: 6em;
    max-width: 300px;
    padding: 0.5em;
    font-size: 1em;
  }

  button {
    text-align: center;
    cursor: pointer;
    font-size:16px;
    color: white;
    border-radius: 4px;
    background-color:#466B6A;
    border: none;
    padding: 0.75em;
    padding-top: 0.25em;
    padding-bottom: 0.25em;
    transition: color 0.5s;
    transition: background-color 0.5s;
  }

  button:hover {
    color: black;
    background-color: #92AAA4;
  }

  button:disabled {
    color: #999;
    background-color: #ccc;
  }

  /* We want the interface to take up the full browser canvas. */
  #application {
    display: flex;
    font-family: sans-serif;
    height: calc(var(--vh, 1vh) * 100 - 1em);
    flex-direction: column;
    flex-wrap: nowrap;
    justify-content: flex-start;
    align-content: stretch;
    align-items: flex-start;
    padding: 1em;
  }

  /* Make sure the chat div does not affect the layout. */
  #chat {
    display: contents;
  }

  #messages {
    list-style: none;
    width: 100%;
    flex: 100 1 auto;
    align-self: stretch;
    overflow-y: scroll;
    background-color: #eee;
    padding: 0.75em;
  }
`

OK, and now, finally, let’s create our socket route to handle the passing of messages between people.

chat.socket

🚧 You should be able to give socket routes the same name as page (regular GET) routes however, this is not functioning correctly in Kitten at the moment. Until then, please name them differently to your page routes. That’s why we haven’t named our socket index.socket here although it would make sense to and be more consistent.

export default function (socket, request) {
  socket.addEventListener('message', event => {
    // A new message has been received: broadcast it to all clients
    // in the same room after performing basic validation.
    const message = JSON.parse(event.data)

    if (!isValidMessage(message)) {
      console.warn(`Message is invalid; not broadcasting.`)
      return
    }

    const numberOfRecipients = socket.all(
      html`
        <div hx-swap-oob="beforeend:#messages">
          <li><strong>${message.nickname}</strong> ${message.text}</li>
        </div>
      `
    )

    // Log the number of recipients message was sent to
    // and make sure we pluralise the log message properly.
    console.log(`  🫧 Kitten ${request.originalUrl} message from ${message.nickname} broadcast to `
      + `${numberOfRecipients} recipient`
      + `${numberOfRecipients === 1 ? '' : 's'}.`)
  })
}

// Some basic validation.

// Is the passed object a valid string?
function isValidString(s) {
  return Boolean(s)                // Isn’t null, undefined, '', or 0
    && typeof s === 'string'       // and is the correct type
    && s.replace(/\s/g, '') !== '' // and is not just whitespace.
}

// Is the passed message object valid?
function isValidMessage(m) {
  return isValidString(m.nickname) && isValidString(m.text)
}

Now, if you run kitten and visit https://localhost you should see the chat interface and be able to send messages. Open another browser window to see the messages appear.

💡 When you send a message, we are not optimistically copying it to the messages list on the client. In fact, our app currently has no custom client-side functionality at all. We’ve declared all dynamic functionally as htmx attributes in the HTML. So that’s why we use the socket’s all() method to send received messages to all clients, including the one that originally sent the message. (Or else the person sending the message would not see it in the message list.) This also means that you can know that the socket is working even if you don’t open another browser tab or window to see the sent messages appear as long as they’re appearing for you in your own window after being sent.

If we were optimistically updating the messages list with client-side logic, we would use the broadcast() method on the socket instead. This method ensures that a message is sent to all clients apart from the one that originally sent it.

Also, due to the way htmx’s WebSocket extension functions, the outer

(or any other outer element you specify in your response) is stripped by htmx before updating the document. So if you view source on your page, you’ll see that only the list items are present in the list.

So we’ve just written a very basic chat app without writing any custom client-side logic at all. That’s pretty cool. But our chat app does have a number of usability issues that we could improve by sprinkling a little custom logic on the client using hyperscript.

Enhancing usability with hyperscript

The most visible and annoying usability issue right now is that when you send a message, the message box is not cleared. So let’s fix that first:

<form id='message-form' ws-send>
  <input 
    id='text' name='text' required 
    script='on htmx:wsSend from #message-form set my value to ""'
  />
</form>

💡 The wsSend event is unique to Kitten at the moment and does not exist in htmx’s WebSocket extension. I will be contributing them upstream when I get a moment.

We also have to tell Kitten that we’re using Hyperscript so it knows to include the client-side library for us. So update the return value of your page route to reflect this:

export default function route (_request, _response) {
  return { markup, styles, uses: [HTMX, HTMX_WEBSOCKET, HYPERSCRIPT] }
}

Run Kitten and test the chat app and note that sent messages are now cleared from the input box.

Ah, that’s better! 😻

But how about the first-launch experience?

When the page initially loads, the message list is empty. If you’ve used the app before, then you’ll know that that’s where the messages go but it’s not overly friendly. So let’s show a placeholder message there when there are no messages and hide it when the first message arrives.

Modify your index.page to add a placeholder list item to the #messages list:

<div id='chat' hx-ext='ws' ws-connect='/chat'>
  <ul id='messages'>
    <li id='placeholder' script='on htmx:load from #chat hide me'>
      No messages yet, why not send one?
    </li>
  </ul>
</div>

Run the app and verify that the placeholder gets hidden when the first message arrives in the message list.

The #chat div that encapsulates the WebSocket connect fires htmx-load events whenever new data is loaded. When the placeholder hears this event, it hides itself.

So that was simple.

But how about this: reduce the height of your chat window so that there is only space for two or three to messages to display. Now send yourself some messages and notice what happens when the fourth or fifth message comes in. They’re added to the list but they aren’t shown on screen as they’re added to the bottom of the chat section.

Let’s fix that by adding a bit more hyperscript to make the chat section scroll to the bottom whenever a new message is received:

<div id='chat' hx-ext='ws' ws-connect='/chat'>
  <ul id='messages' script='on htmx:load from #chat set my scrollTop to my scrollHeight'>
  </ul>
</div>

Adding a status indicator

Since a WebSocket is a persistent connection, it would be good to know when we get disconnected. htmx’s WebSocket extension does a good job of queuing messages when this happens but it would help if we knew that we were offline (either because our Internet connection is disrupted or because the server has died).

So let’s add a status indicator component that uses hyperscript to achieve this:

StatusIndicator.component

export default function StatusIndicator ({ target }) {
  return html`
    <p>Status: <span id='status' script='
      on htmx:wsConnecting from ${target}
        put "Connecting…" into me
        remove .online from me
        remove .offline from me
      end

      on htmx:wsOpen from ${target}
        put "Online" into me
        add .online to me
        remove .offline from me
      end

      on htmx:wsClose from ${target}
        put "Offline" into me
        add .offline to me
        remove .online from me
      end
    '>Initialising…</span></p>

    <style>
      .online {color: green}
      .offline {color: red}
    </style>
  `
}

🚧 Notice that we’ve inlined the CSS into the HTML here. While this is fine for simple CSS, it won’t work for more involved rules (e.g., child selectors) as the html template tag will escape characters like the greater-than symbol. In the future, you’ll be able to either return { markup, styles } from components like you can from pages and/or the html parsing will be more intelligent about escaping with embedded styles, etc.

💡 The wsConnecting, wsOpen, and wxClose events are unique to Kitten at the moment and do not exist in htmx’s WebSocket extension. I will be contributing them upstream when I get a moment.

Finally, let’s add the StatusIndicator component to our index.page:

import styles from './index.styles'
import script from './index.script'
import StatusIndicator from './StatusIndicator.component'

const markup = html`
  <h1>😸 <a href='https://kitten.small-web.org'>Kitten</a> Chat</h1>
  <${StatusIndicator} target='#chat'/>
  <div id='chat' hx-ext='ws' ws-connect='/chat'>
`

Now, when you run Kitten Chat, you should see the indicator turn green when you’re online.

Stop Kitten and note that the indicator turns red and shows you that you’re offline.

Restart Kitten and note the indicator turn green again once the app reconnects.

🚧 The htmx WebSocket extension implements an exponential backoff algorithm that keeps trying to connect to the server after exponentially longer waiting periods after getting disconnected. There is an issue with this implementation where this interval does not reset even if you close the browser tab. You actually have to restart the browser for the interval to reset. I’m going to look into filing a bug about this and hopefully contribute a fix upstream once I get a chance.

Adding final touches for mobile devices using custom JavaScript

While hyperscript lets you do commonly done things easily, it’s sometimes easier to just pop out into JavaScript. You can easily do so by creating a .script file that will be added to your page as an inline <script> element.

In this case, we want to use a trick to ensure that our interface displays correctly even when the address bar is visible in mobile browsers. This is a hacky workaround (oh, hello, welcome to web development) so let’s add a .script file to include it:

index.script

export default _ => {
  // Fix on mobile so interface takes up full-screen.
  function fixInterfaceSizeOnMobile () {
    function resetHeight() {
      let vh = window.innerHeight * 0.01;
      document.querySelector(':root').style.setProperty('--vh', `${vh}px`)
      // document.documentElement.style.setProperty('--vh', `${vh}px`)
    }

    resetHeight()

    window.onresize = function (_event) {
      resetHeight()
    }
  }
  
  window.addEventListener('load', fixInterfaceSizeOnMobile)
}

While this is an issue that only surfaces on mobile devices, running the fix does not have a negative effect on desktop browsers so we just always run it when the page first loads by listening for the load event on the window.

💡 Notice how the .script file exports a function. Kitten takes the source code of this function and appends it to your page within a <script>…</script> section. Keep in mind that although you’re writing JavaScript code just like in your page and socket routes, this code is meant to run in the client so it never executes on the server.

Focus is hard sometimes

Finally, let’s make one last usability improvement. Wouldn’t it be nice if we could keep chatting after sending a message without having to constantly click in the chat box? (In other words, if the chat box kept focus after sending a message, even if we click the Send button, for example.)

This is very easy to do using hyperscript. Modify #text input in the #message-form as shown below:

<form id='message-form' ws-send>
  <input 
    id='text' name='text' required script='
      on htmx:wsSend from #message-form
        set my value to ""
        focus() me
  '/>
</form>

Now run the example on a desktop computer and notice that the message box keeps its focus even if you press the Send button.

That’s nice!

But now run the example on a mobile phone with a virtual keyboard. Ah. By keeping focus on the message field, we’re stopping the keyboard from hiding. And that means that we can’t see the message we just sent. That’s less than ideal. So let’s implement another little hack by defining a function that tries to detect if the person is on mobile (remember that none of these hacks are ideal) and then let’s see how we can call that JavaScript from our hyperscript.

First, add a function called isMobile() to index.script:

export default _ => {
  globalThis.isMobile = _ => {
    return (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|ipad|iris|kindle|Android|Silk|lge |maemo|midp|mmp|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i.test(navigator.userAgent)
    || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(navigator.userAgent.substr(0,4)))
  }

  // …
}

💡 Notice how we declared the function in global scope so we can call it from hyperscript.

Now, modify your index.page one last time to only focus the field if the person is not on a mobile device:

<form id='message-form' ws-send>
  <input 
    id='text' name='text' required script='
      on htmx:wsSend from #message-form
        -- Clear the text input.
        set my value to ""

        -- On mobile, don’t refocus the input box
        -- in case there is a virtual keyboard so
        -- the person can see the message they just sent.
        if not isMobile() then focus() me
  '/>
</form>

💡 Take note that this approach is brittle. We are using the user agent string to make a best guess effort whether the person is on a mobile device. User agent strings can be spoofed. Beyond that, just because a person is on a mobile device, it doesn’t mean that they’re using a virtual keyboard. They could have a physical keyboard attached. Unfortunately, there isn’t a built-in way of detecting from a web page whether someone is using a virtual keyboard. Although there are other hacks you might want to try.

You can find the final version of this example is the examples/kitten-chat folder.

Persistent Kitten Chat

While we were able to improve the usability of the Kitten Chat example by sprinkling a little hyperscript here, a little JavaScript there on the client, there is one limitation that we need to implement more server-side functionality to overcome: the messages are not persisted.

If two people are having a chat and someone else enters the room, they don’t see the messages that have already been sent.

JavaScript Database (JSDB) to the rescue once again!

What we need to do is to persist messages when they arrive in our chat.socket and display the messages that are already in our database while rendering our index.page. Since both the socket and page route now need to create messages, let’s start by creating a Message component that can be used by both of them:

Message.component

export default function Message ({ message }) {
  return html`<li><strong>${message.nickname}</strong> ${message.text}</li>`
}

This component simply takes a message object and render a list item that shows the person’s nickname and the text of their message.

Now, let’s refactor our WebSocket route (index.socket) to:

  • Create our messages table (if it doesn’t exist),
  • Persist received messages to the messages table,
  • Render messages using the Message component and send them to all connected clients.

chat.socket

import Message from './Message.component'

// Ensure the messages table exists in the database before using it.
if (db.messages === undefined) db.messages = []

// Kitten Chat example back-end.
export default function (socket, request) {
  socket.addEventListener('message', event => {
    const message = JSON.parse(event.data)

    if (!isValidMessage(message)) {
      console.warn(`Message is invalid; not broadcasting.`)
      return
    }
    
    // We don’t need to use the message HEADERS, delete them so
    // they don’t take unnecessary space when we persist the message.
    delete message.HEADERS

    // Persist the message in the messages table.
    db.messages.push(message)

    const numberOfRecipients = socket.all(
      html`
        <div hx-swap-oob="beforeend:#messages">
          <${Message} message='${message}' />
        </div>
      `
    )
    // …
  }
}
// …

💡 Messages sent by the htmx WebSocket extension include a htmx-specific 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 and text so we delete the HEADERS 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

import styles from './index.styles'
import script from './index.script'
import Message from './Message.component'
import StatusIndicator from './StatusIndicator.component'

// Ensure the messages table exists in the database before using it.
if (db.messages === undefined) db.messages = []

export default function route (_request, _response) {
  const markup = html`
    <div id='chat' hx-ext='ws' ws-connect='/chat'>
      <ul id='messages' _='on htmx:load from #chat set my scrollTop to my scrollHeight'>
        ${(messages => {
          if (messages.length === 0) {
            return html`
              <li id='placeholder' _='on htmx:load from #chat hide me'>
                No messages yet, why not send one?
              </li>
            `
          } else {
            return messages.map(message => html`<${Message} message=${message} />`)
          }
        })(db.messages)}
      </ul>
    </div>
  `

  return { markup, styles, script }
}

💡 Since the conditional logic in our template is somewhat verbose, I chose to use an immediately-invoked arrow function expression to encapsulate it (so I could express the conditional using and if and else blocks:

${(messages => {
  if (messages.length === 0) {
    return html`
      <li id='placeholder' _='on htmx:load from #chat hide me'>
        No messages yet, why not send one?
      </li>
    `
  } else {
    return messages.map(message => html`<${Message} message=${message} />`)
  }
})(db.messages)}

If that’s confusing to read, you can also write it as a regular immediately-invoked function expression (IIFE):

${(function (messages) {
  if (messages.length === 0) {
    return html`
      <li id='placeholder' _='on htmx:load from #chat hide me'>
        No messages yet, why not send one?
      </li>
    `
  } else {
    return messages.map(message => html`<${Message} message=${message} />`)
  }
})(db.messages)}

Or, if that’s still confusing, you can always use a conditional (ternary) operator. Notice how you refer to db.messages directly if you do this.

${
  db.messages.length === 0 ?
    html`
      <li id='placeholder' _='on htmx:load from #chat hide me'>
        No messages yet, why not send one?
      </li>
    `
  : db.messages.map(message => html`<${Message} message=${message} />`)
}

All three of these approaches are equivalent. Feel free to use the one that reads best for you.

And that’s it: now when you run the app and load it in your browser, you will see any messages that were sent previously when the page first loads.

I guess persistence really does pay off.

(I’m here all week. 😸)

App-specific password

Kitten automatically generates a cryptographically secure password for each app. You can use this, for example, to implement authentication for the person who owns the app if there is an admin section. (Remember that Kitten/the Small Web is for individuals, not large organisations or corporations.)

🚧 (In time, Kitten might add its own session-handling and authentication mechanism, back-ported from Domain).

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 or create an Application Programming Interface (API) that returns data in JSON instead of interfaces and fragments in HTML?

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 files and WebSocket routes in .socket 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, .post, .patch, .head, etc.)

e.g.,

my-project
  ├ index.page
  ├ index.post
  ├ about
  │   ╰ index.page
  ├ todos
  │   ╰ index.get
  ╰ chat
     ╰ index.socket

Optionally, to organise larger projects, you can encapsulate your site within a src folder. If a src folder does exist, Kitten will only serve routes from that folder and not from the project root.

e.g.,

my-project
  ├ src
  │  ├ index.page
  │  ├ index.post
  │  ├ index.socket
  │  ╰ about
  │      ╰ index.page
  ├ test
  │   ╰ index.js
  ╰ README.md

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

if (!db.entries) db.entries = []

export default _ => {
  const markup = 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>

    ${db.entries.length === 0 ?
      html`<p>Hey, no one’s signed yet… be the first?</p>`
    :''}

    <ul>
      ${db.entries.map(entry => html`
        <li>
          <p class='message'>${entry.message}</p>
          <p class='nameAndDate'>${entry.name} (${new Date(entry.date).toLocaleString()})</p>
        </li>
      `)}
    </ul>
 `

  const styles = css`
    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; }
  `
  
  return { markup, styles }
}

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>

It’s 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:

if (!db.entries) db.entries = []

export default (request, response) => {
  // Basic validation.
  if (!request.body || !request.body.message || !request.body.name) {
    response.writeHead(403)
    return
  }
  
  db.entries.push({
    message: request.body.message,
    name: request.body.name,
    date: Date.now()
  })
  
  response.writeHead(303, { Location: '/' })
}

And that’s it.

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.

You can test this out 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
Date: Thu, 08 Sep 2022 16:01:17 GMT
Connection: keep-alive
Keep-Alive: timeout=5

(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' http

This time, we get a much nicer response:

HTTP/1.1 303 See Other
Location: /
Date: Thu, 08 Sep 2022 16:03:20 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

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.

Valid file types

Kitten doesn’t force you to put different types of routes into predefined folders. Instead, it uses file extensions to know how to handle different routes and other code and assets.

Here is a list of the main file types Kitten handles and how it handles them:

Extension Type Behaviour
.page Kitten page Compiled into HTML and served in response to a HTTP GET request for the specified path.
.post, .get, .put, .head, .patch, .options, .connect, .delete, .trace HTTP route Served in response to an HTTP request for the specified method and path.
.socket WebSocket route Served in response to a WebSocket request for the specified path.
.component A component file, returns HTML Ignored by router.
.fragment A fragment file, returns HTML Ignored by router.
.script Server-side script file Ignored by router. Useful for including server-side JavaScript modules. File is not accessible from the client.
.styles 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 in the root of your source folder.

The content of HTTP routes is an ESM module that exports a standard Node route request handler that takes http.IncomingMessage and http.ServerResponse arguments.

For example, your books.get route might look like this:

export default (request, response) => {
  const books = db.books.get()
  response.end(books)
}

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.

Learn more about JSDB.

Route parameters

You can include route parameters in your route paths by separating them with underscores and surrounding the parameter names in square brackets.

For example:

manage_[token]_[domain].socket

Will create a WebSocket endpoint at:

/manage/:token/:domain

You can also intersperse path fragments with parameters:

books_[id]_pages_[page].page

Will compile the Kitten page and make it available for HTTP GET requests at:

/books/:id/pages/:page

So you can access the route via, say, https://my.site/books/3/pages/10.

You can also specify the same routes using folder structures. For example, the following directory structure will result in the same route as above:

my-site
  ╰ books
     ╰ [id]
         ╰ pages
             ╰ [page].page

Note that you could also have set the name of the page to index[page].page_. Using just [page].page for a parameterised index page is a shorthand.

You can decide which strategy to follow based on the structure of your app. If, for example, you could access not just the pages but the references and images of a book, it might make sense to use a folder structure:

my-site
  ╰ books
     ╰ [id]
         ├ pages
         │   ╰ [page].page
         ├ references
         │   ╰ [reference].page
         ╰ images
             ╰ [image].page

You may, or may not find that easier to manage than:

my-site
  ├ books_[id]_pages_[page].page
  ├ books_[id]_references_[reference].page
  ╰ books_[id]_images_[image].page

Kitten leaves the decision up to you.

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, .socket, .post, etc.), will be served as a static file.

Command-line interface

serve

Default command.

💡 kitten serve [path to serve] and kitten [path to serve] are equivalent.

Note that if do not specify a path to serve, the default directory (./) is assumed.

--version

Displays the version number.

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

💡 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 takes less than half a second on a modern computer and has the additional benefit of informing you of compile-time errors.

Debugging

To run Kitten with the Node debugger (node --inspect), start it using:

bin/kitten-inspect [path to serve]

💡 If you use VSCodium, you can add breakpoints in your code and attach to the process using the Attach command in the Run and Debug panel.

Testing

Tests are written in Tape With Promises, run using ESM Tape Runner, and displayed using Tap Monkey.

Coverage is provided by c8.

Run tests:

npm -s test

Run coverage:

npm run -s coverage

💡 The -s just silences the npm logs for cleaner output.

🚧 Tests are in the process of being ported from NodeKit to Kitten.

Continuous integration

I’m in the process of setting up Woodpecker CI on Codeberg. Initially, it will only run the build and unit tests.

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.
esbuild An extremely fast JavaScript bundler. Used to bundle hydration scripts and NodeScript routes during server-side rendering.
node-git-server Git server for hosting your source code. Used in deployments.
isomorphic-git Git client used in deployments on development and for handling auto-updates on production.
sade A small command-line interface (CLI) framework that uses mri for its argument parsing.

Frequently-Asked Questions (FAQs)

What about serverless?

Dude, this is literally a server. If you want “serverless” (funny how folks who own servers want you to go serverless, isn’t it? It’s almost like a small group of people get to own stuff and you have to rent from them on their terms… hmm 🤔) then use some Big Tech framework like SvelteKit. They will bend over backwards to cater to all your Big Tech needs.

Can you add <insert Big Tech feature here>?

No, go away.

Will this scale?

Fuck off.

(Yes, it will scale for the purposes it was designed for. It will not scale for the purposes of farming the population for their data and destroying our human rights and democracy in the process. That’s a feature, not a bug.)

Is there anything stopping me from using this to build sites or apps that violate people’s privacy and farm them for their data? (You know, the business model of Silicon Valley… that thing we call surveillance capitalism?)

No, there is nothing in the license to stop you from doing so.

But I will fucking haunt you in your nightmares.

(Just sayin’)

Also, it’s not nice. Don’t.

Is this really a Frequently Asked Questions section or a political statement?

Can’t it be both?

(It’s a political statement.)

Ideas