A zero-configuration small web toolkit that includes a production server and development platform for making small web sites using Node.js, Svelte, and JavaScript Database (JSDB).
This repository has been archived on 2024-06-13. You can view files and clone it, but cannot push or open issues or pull requests.
Find a file
2022-08-22 09:59:32 +01:00
bin WIP: Kitten, initial implementation 2022-07-22 16:30:40 +01:00
build-templates Add Kitten build templates 2022-08-19 10:10:33 +01:00
examples Add persisted kitten chat example 2022-07-31 13:19:04 +01:00
src Expose WebSocket class globally for use in routes 2022-07-29 13:05:40 +01:00
tests Rename <data>…</data> block to <get>…</get> 2022-07-06 14:23:22 +01:00
.gitignore Don’t crash if script not present in page 2022-01-20 16:51:26 +00:00
.nvmrc Update version in .nvmrc to latest supported 2022-05-26 09:18:04 +01:00
build WIP: Build script migration; comment out old dependencies 2022-08-19 10:00:47 +01:00
dev-install Add install and dev-install scripts 2022-08-22 09:59:32 +01:00
install Add install and dev-install scripts 2022-08-22 09:59:32 +01:00
package-lock.json Add latest package-lock file 2022-07-26 11:36:47 +01:00
package.json Add static file middleware and htmx library 2022-07-26 11:36:30 +01:00
README.md Update readme 2022-07-31 16:36:01 +01:00
suppress-experimental.cjs Remove undici now fetch API is in Node; bump minimum Node version to 18+ 2022-06-02 17:05:49 +01:00

😸️ Kitten

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

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

🍼 Warning: Kitten is just being born.

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

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

System requirements

  • Node 18+ LTS.
  • Linux

For production servers:

  • systemd

Install

  1. Clone this repository

    git clone https://codeberg.org/kitten/app.git kitten
    
  2. Switch to the Kitten directory.

    cd kitten
    
  3. 🚧 Install.

    ./install
    

Getting started

🚧 You can run Kitten using the following syntax:

kitten [path to serve]

During development, you can also run Kitten from the source folder like this:

bin/kitten [path to serve]

🍼 Workaround: Start Kitten in production mode using the development syntax:

PRODUCTION=true bin/kitten [path to serve]

(Development mode has not yet been implemented and the build script has not been updated yet either.

If you want to play around with development mode, please checkout and play with the prototype branch.

Production

In production mode, 🚧 Kitten runs as a systemd service.

🍼 Production mode currently runs as a regular process.

You can start Kitten in production mode by setting the PRODUCTION environment variable to a non-empty value. e.g.,

PRODUCTION=true bin/kitten examples/simple-chat/

💡 By default, Kitten will be as quiet as possible in the console and only surface warnings and errors.

If you want more extensive logging, you can start it with the VERBOSE environment variable set:

VERBOSE=true kitten [path to serve]

or

VERBOSE=true bin/kitten [path to serve]

Similarly, if you want to see performance statistics, set PROFILE=true.

Examples

The best way to get started is to play with the examples.

  • Hello Count: examples/hello-count
  • Persisted Hello Count: examples/persisted-hello-count
  • Kitten Chat: examples/kitten-chat
  • Make Fetch Happen: examples/make-fetch-happen
  • Streaming Fediverse Posts: examples/streaming-fediverse-posts

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

kitten examples/simple-chat

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

Tutorials

🍼 Substitute PRODUCTION=true bin/kitten for kitten in the tutorial instructions for the time being.

Hello, world!

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

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

🍼 For the time being, please call the file index.page.

<h1>😸️ Meow!</h1>

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

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

🍼 For the time being, if you have static assets you want to serve, put them in a special folder called #static. So you can put your HTML into #static/index.html and the outcome will be identical. This limitation will be removed shortly and Kitten will automatically serve .html, .css, .js, etc., files as any other web server would without any configuration or special effort on your part.

But you can render HTML using any web server…

Let’s do something that no other web server can do now, shall we?

Counting kittens.

Rename your index.html file to index.page and replace its contents with the following:

let count = 1

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

Now run kitten, goto https://localhost, and refresh the page to see the number of kittens increase each time you do.

Ooh, magic!

How does it work?

A page in Kitten is written in plain old JavaScript.

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

Routes are just exported default function inside of JavaScript modules. Any HTML you return from them is sent to browsers.

You write your HTML inside of 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 that there’s really no magic involved).

let count = 1

export default function route (_request, _response) {
  return html`
    <h1>Kitten count<h1>
    ${'😸️'.repeat(count++)}
  `
}

With the above version of the code, it’s clear that what you’re exporting from the .page file is a route and that it gets passed the HTTP request and response objects.

Underscores?

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

Persistence is the secret to success (or something)

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

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

So let’s fix that.

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

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

Update your code to match this:

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

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

Now load the page, refresh, stop the server, restart it, and load the page again…

Wait, what?

That’s it?

Seriously?

Yep, that’s the magic of the integrated JavaScript Database (JSDB) in Kitten.

If you don’t believe me, restart the server and note that all your kittens are still there.

If you still don’t believe me (wow, what a cynic), look in the ~/.config/small-tech.org/kitten/database folder and you should see a folder there that mirrors the path of your project (e.g., if your project is in /var/home/aral/projects/greetings, the folder will be var.home.aral.projects.greetings). Inside your project’s database folder, you should see a kittens.js file. Open it up in a text editor and take a look. You should see something like this:

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

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

Yes, you need never fear persistence ever again.

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

Fetchiverse

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

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

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

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

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

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

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

  return { markup, styles }
}

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

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

Now, run kitten with:

kitten make-fetch-happen

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

💾 This example is available in examples/fetchiverse.

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

Components and fragments

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

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

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

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

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

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

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

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

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

index.page

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

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

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

Avatar.component

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

Content.component

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

styles.fragment

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

💡 Separating your pages into components and fragments should make your sites easier to maintain but this does come at the expense of locality of behaviour (keeping related functionality together so you can easily read through the code in a linear fashion). In this example, I probably would have kept the 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 })
}

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 _/updates (ws-connect='/updates')

Believe it or not, that’s all the code you need on the client to set up and manage a WebSocket connection. Kitten will include htmx and the htmx WebSockets extension automatically in your page and serve it from your site so you don’t have to worry about setting up any of that either.

😻 When I said Kitten loves you, I meant Kitten loves you.

Post.component

Notice how we’ve refactored the fetchiverse example so that we now have a Post component. We’ve also added the simple Avatar and Content components to the same file in the name of locality of behaviour.

export default function Post ({ post }) {
  return html`
    <li>
      <${Avatar} post=${post} />
      <${Content} post=${post} />
    </li>
  `
}

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

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

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

💡 None of the code in the Post, Avatar, 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 }
}

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.

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. 😸)

📝 TODO: KITTEN REWRITE. LEFT OFF HERE.

APIs and working with data

For many projects, you should be able to keep your both your client and server code in the same .page file using NodeScript.

However, if you have an API or purely data-related routes, you can create server-side routes by creating files with any valid HTTP1/1.1 method lowercased as the file extension (i.e., .get, .post, .patch, .head, etc.)

Also, you can create a WebSocket route simply by creating a .socket file.

e.g.,

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

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

e.g.,

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

Valid file types

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

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

Extension Type Behaviour
.page Kitten page (supports NodeScript) Compiled into HTML and served in response to a HTTP GET request for the specified path.
.get, .head, .patch, .options, .connect, .delete, .trace, .post, .put HTTP route Served in response to an HTTP request for the specified method and path.
.socket WebSocket route Served in response to a WebSocket request for the specified path.
.component Svelte component Ignored by router.
.svelte Svelte component (.component is just an alias for .svelte) Ignored by router.
.js Javascript module Ignored by router.

HTTP routes

HTTP data routes are served in response to an HTTP request for the specified method and path.

All HTTP request methods are supported.

You create an HTTP route by create a JavaScript file named with the HTTP request method you want to respond to.

For example, to respond to GET requests at /books, you would create a file named books.get in the root of your source folder.

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

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

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

WebSocket routes

WebSocket routes are defined in files ending with the .socket extension.

They resemble HTTP routes but get the socket passed in as an extra initial parameter.

For example, here’s a basic echo.socket route that repeats whatever it receives back to you:

export default (socket, request, response) => {
  socket.addEventListener('message', event => {
    socket.send(event.data)
  })
}

💡 Since we’re not using the request or response objects in this route, we could have just left them off of the function signature.

And a simple index.page route that uses it:

<socket>
  export default (socket, request, response) => {
    socket.addEventListener('message', event => {
      socket.send(event.data)
    })
  }
</socket>

<script>
  import { onMount } from 'svelte'

  let messageField

  let socket
  let message = ''
  let messages = []

  onMount(() => {
    // Initialise the web socket
    socket = new WebSocket(`wss:///`)

    socket.addEventListener('open', event => {
      socket.send('Hello, there!')
    })

    socket.addEventListener('message', event => {
      messages = [...messages, event.data]
    })
  })
</script>

<h1>WebSocket Echo Demo</h1>

<form id='messageForm' on:submit|preventDefault={event => {
  socket.send(message)
  message = ''
  messageField.focus()
}}>
  <label>Message:
    <input type='text' bind:this={messageField} bind:value={message}>
  </label>
  <button type='submit'>Send</button>
</form>

<h2>Received messages</h2>

<ul id='received'>
  {#each messages as message}
    <li>{message}</li>
  {/each}
</ul>

And, just like with inline <get>…</get> routes, you can also inline WebSocket routes so they share the same route as the page. So all the code for the example, above, can be added to the same page:

<script>
  import { onMount } from 'svelte'

  let messageField

  let socket
  let message = ''
  let messages = []

  onMount(() => {
    // Initialise the web socket
    socket = new WebSocket(`wss://localhost/echo`)

    socket.addEventListener('open', event => {
      socket.send('Hello, there!')
    })

    socket.addEventListener('message', event => {
      messages = [...messages, event.data]
    })
  })
</script>

<h1>WebSocket Echo Demo</h1>

<form id='messageForm' on:submit|preventDefault={event => {
  socket.send(message)
  message = ''
  messageField.focus()
}}>
  <label>Message:
    <input type='text' bind:this={messageField} bind:value={message}>
  </label>
  <button type='submit'>Send</button>
</form>

<h2>Received messages</h2>

<ul id='received'>
  {#each messages as message}
    <li>{message}</li>
  {/each}
</ul>

Database

Kitten has an integrated JSDB database that’s available from all your routes as db.

JSDB is a transparent, in-memory, streaming write-on-update JavaScript database for the Small Web that persists to a JavaScript transaction log.

You can find the databases for your projects in the ~/.small-tech.org/kitten/database folder. Each project gets its own folder in there with a name based on the absolute path to your project on your disk (e.g., if a Kitten project is stored in /var/home/aral/projects/my-project, its database will be in a folder named var.home.aral.projects.my-project in the main database folder.)

Tables in JSDB are simply JavaScript objects or arrays and JSDB writes to plain old JavaScript files.

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.

Multiple roots

For larger projects, you might want to organise your routes, say, to separate your pages from your API. You can specify any folder within your source to be a new route by prefixing its name with an octothorpe (hash symbol/#).

For example, you can split the following directory structure:

my-project
  ├ index.page
  ├ contacts.page
  ╰ contacts.post

As:

my-project
  ├ index.page
  ╰ #api
    ├ contacts.page
    ╰ contacts.post

Or even:

my-project
  ├ #pages
  │   ├ index.page
  │   ╰ contacts.page
  ╰ #api
      ╰ contacts.post

In all of the above versions, HTTP GET calls to /contacts will find contacts.page and HTTP POST calls to /contacts will find contacts.post.

(If you wanted your contacts.post route to be accessible from /api/contacts instead, you would just remove the # and make it a regular folder.)

Static files

If you want Kitten to serve static files, put them in a special folder called #static. This is a special case of the multiple roots feature explained above, where any files (excluding dotfiles) are served as static elements.

For example:

my-project
  ├ index.page
  ╰ #static
    ├ header.svg
    ╰ demo.mp4

Command-line interface

serve

Default command.

💡 kitten serve [path to serve] 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.

Currently does not exit the process unless when run from the distribution build.

⚠️ Building Kitten

To build a distribution bundle for Kitten, run:

./build

You will find the distribution under the dist/ folder.

To run Kitten from the distribution folder, use the following syntax:

./kitten [path to serve]

💡 It’s usually easier just to run bin/kitten [path to serve] without building or, to test the distribution build, the ./quick-install script as that will run build for you and install the kitten command into your path so you can run it as kitten [path to serve]

⚠️ Debugging

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

bin/kitten-inspect [path to serve]

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

Testing

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

Coverage is provided by c8.

Run tests:

npm -s test

Run coverage:

npm run -s coverage

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

Technical design

Kitten is an ESM-only project for Node.js and relies on (the currently experimental) ES Module Loaders (follow the latest work) functionality.

Additionally, Kitten relies on a number of core dependencies for its essential features.

Core dependencies

Dependency Purpose
@small-tech/https Drop-in replacement for Node’s native https module with automatic TLS for development and production using @small-tech/auto-encrypt and @small-tech/auto-encrypt-localhost.
@small-tech/jsdb Zero-dependency, transparent, in-memory, streaming write-on-update JavaScript database that persists to JavaScript transaction logs.
Polka@next Native HTTP server with added support for routing, middleware, and sub-applications. Polka uses Trouter as its router.
tinyws WebSocket middleware for Node.js based on ws.
Svelte Interface framework. NodeScript, used in Pages, is an extension of Svelte. Components, used in Pages, are Svelte components.
esbuild An extremely fast JavaScript bundler. Used to bundle hydration scripts and NodeScript routes during server-side rendering.
node-git-server Git server for hosting your source code. Used in deployments.
isomorphic-git Git client used in deployments on development and for handling auto-updates on production.
sade A small command-line interface (CLI) framework that uses mri for its argument parsing.

Frequently-Asked Questions (FAQs)

What about serverless?

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

Can you add <insert Big Tech feature here>?

No, go away.

Will this scale?

Fuck off.

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

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

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

But I will fucking haunt you in your nightmares.

(Just sayin’)

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

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

Can’t it be both?

(It’s a political statement.)

Ideas