Kitten

Start by creating a basic initial ephemeral, unauthenticated chat application and then build upon it to create an authenticated, peer-to-peer, end-to-end encrypted Small Web chat application using Kitten’s unique first-class support for route authentication, ed25519 identities, and higher-level cryptogaphic primitives.

Topics covered

🚧 This tutorial has not been updated to use Kitten’s new Streaming HTML workflow yet but it will be. In the meanwhile, you can compare code here with the Streaming HTML versions of the Kitten Chat examples.

Kitten chat

You can do a lot by using a WebSocket server to communicate with your application. You can use it, for example, in place of POST requests to send remote commands to the server and get asynchronous results back. That’s really powerful in creating responsive applications. But you’re not limited to sending messages to just one page. You can also broadcast messages to all connected pages.

To see how that works, let’s create a simple chat example, starting with the index page:

index.page.js

import styles from './index.styles.js'

// The page template is static so we render it outside
// the route handler for efficiency (so it only gets rendered
// once when this file first loads instead of on every request).
export default () => kitten.html`
  <page htmx htmx-websocket>

  <main>
    <h1>🐱 <a href='https://codeberg.org/kitten/app'>Kitten</a> Chat</h1>

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

      <form id='message-form' ws-send>
        <label for='nickname'>Nickname:</label>
        <input
          id='nickname' name='nickname' type='text' required
          @htmx:ws-after-send.window='$el.value = ""'
        >
        <label for='text'>Message:</label>
        <input id='text' name='text' type='text' required />
        <button id='sendButton' type='submit'>Send</button>
      </form>
    </div>
  </main>

  ${[styles]}
`

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

🐈 We’ve wrapped our entire interface a <main> tag. This is because we are going to add styles that wouldn’t work when applied to the <body> tag that Kitten renders your page onto. You might have seen mainstream web frameworks render pages into a special div. Kitten tries to keep special cases to a minimum and doesn’t do that.

šŸ’” We could have just inlined the styles into the HTML block but since they’re quite verbose, we decided to put them into their own file. Note that the syntax we’re using to include (interpolate) the styles into the HTML. By wrapping them in an array, we are telling HTMX to bypass any sanitisation it may otherwise perform on interpolated values. Needless to say, only do this with trusted content and never with data you obtain from an API call, etc. In those instances, use the global kitten.safelyAddHtml() function.

index.styles.js

export default kitten.css`
  * { box-sizing: border-box; }

  /* Make interface fill full browser canvas. */
  main {
    display: flex;
    font-family: sans-serif;
    height: calc(var(--vh, 1vh) * 100 - 1em);
    flex-direction: column;
    flex-wrap: nowrap;
    justify-content: flex-start;
    align-content: stretch;
    align-items: flex-start;
    padding: 1em;
  }

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

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

  a {
    color: #334b4c;
  }

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

  form > label { grid-column: labels; }

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

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

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

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

  /* The chat div should not affect the layout. This is actually a smell.
   It is purely being used to declaratively create a WebSocket connection.
   Need to think about this.
  */
  #chat {
    display: contents;
  }

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

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

chat.socket route

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

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

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

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

// Some basic validation.

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

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

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

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

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

Also, due to the way htmx’s WebSocket extension functions, the outer <div> (or any other outer element you specify in your response) is stripped by htmx before updating the document. So if you view source on your page, you’ll see that only the list items are present in the list.

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

Let’s start with the first-launch experience…

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

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

<div
  id='chat'
  hx-ext='ws'
  ws-connect='/chat.socket'
  x-data='{ showPlaceholder: true }'
>
  <ul id='messages' @htmx:load='showPlaceholder = false'>
    <li id='placeholder' x-show='showPlaceholder'>
      No messages yet, why not send one?
    </li>
  </ul>
  …
</div>

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

The unordered list receives htmx:load events whenever new data (a list item) is loaded. When it hears this, it sets the showPlaceholder flag to false. The placeholder list item is shown or hidden based on the state of this flag using the x-show attribute.

The flag itself is declared in the parent <div> using an x-data attribute.

So that was simple.

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

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

<div
  id='chat'
  hx-ext='ws'
  ws-connect='/chat.socket'
  x-data='{ showPlaceholder: true }'
>
  <ul id='messages' @htmx:load='
    showPlaceholder = false
    $el.scrollTop = $el.scrollHeight
  '>
    <li id='placeholder' x-show='showPlaceholder'>
      No messages yet, why not send one?
    </li>
  </ul>
  …
</div>

Getting syntax highlighting for Alpine.js

It might be that your editor supports syntax highlighting for embedded Alpine.js code by default. If so, that’s great and you can skip this section.

If it doesn’t, there’s a little trick you can use to get it in any editor that supports syntax highlighting for tagged template literals. Kitten comes with a simple one called kitten.js that simply passes through anything it’s passed. If you use it, however, you will get syntax highlighting for embedded code in editors like Helix Editor. It does add complexity to the code, however, and gives you yet another quote mark that you might have to escape if it appears in your code. For larger pieces of code, it is best to use a standard JavaScript file and include it at runtime in your page.

All that said, this is what the above code would look like if you used it:

<div 
  id='chat'
  hx-ext='ws'
  ws-connect='/chat.socket'
  x-data='${kitten.js`{ showPlaceholder: true }`}'
>
  <ul id='messages' @htmx:load='${kitten.js`
    showPlaceholder = false
    $el.scrollTop = $el.scrollHeight
  `}'>
    <li id='placeholder' x-show='showPlaceholder'>
      No messages yet, why not send one?
    </li>
  </ul>
  …
</div>

Adding a status indicator

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

So let’s add a status indicator component that uses Alpine.js to achieve this:

StatusIndicator.component.js

export default () => kitten.html`
  <p>Status: <span 
    id='status'
    x-data='{ status: "Initialising…" }'
    @htmx:ws-connecting.window='status = "Connecting…"'
    @htmx:ws-open.window='status = "Online"'
    @htmx:ws-close.window='status = "Offline"'
    x-text='status'
    :class='
      status === "Online" ? "online" : status === "Offline" ? "offline" : ""
    '>
    Initialising…
  </span></p>

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

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

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

export default () => kitten.html`
  <h1>🐱 <a href='https://codeberg.org/kitten/app'>Kitten</a> Chat</h1>

  <${StatusIndicator} />

  <div
    id='chat'…
`

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

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

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

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

Debugging htmx

If some of the htmx stuff seams rather opaque and magical to you, that’s because, to a degree, it is. Making what htmx is doing visible by logging it might help you to debug your site or app when things go awry.

To that end, let’s implement an htmx logger.

While Alpine.js lets you do commonly done things easily, it’s sometimes easiest just to pop out into plain old JavaScript for more convoluted things.

Since this is just a regular web page, you can add any number of <script> tags to it and load in external JavaScript by specifying its path.

So let’s add a client-side .js file and implement our htmx logger in that.

šŸ’” Note that .js files are just regular client-side JavaScript files. They are served from your server like any other static file. Make sure you don’t put anything sensitive (like API keys, etc.) in them.

index.js

function onLoad() {
  // For debugging: log out htmx events to the console.
  htmx.logger = function(elt, event, data) {
    if(console) {
      console.info(event, elt, data)
    }
  }
}

window.addEventListener('load', onLoad)

šŸ’” The htmx global is available when htmx is included on the page so we wait for the documentā€˜s load event before attempting to attach the debugger.

Of course, just creating our script file isn’t enough, we also need to load it from the page. We could just add it to the end of our page (maybe after the closing <main> tag) but then we’d have a problem: we couldn’t guarantee that it gets loaded after htmx itself does. (And our logger function relies on htmx having loaded so that the htmx global is available.)

What we really want is to add our script to the page after libraries like htmx have loaded. We can do this using the special page slots and the special Kitten tag called <content> which we saw earlier in the Layout Components section:

  …
  </main>
  
  <content for='AFTER_LIBRARIES'>
    <script src='./index.js' />
  </content>

  ${[styles]}

šŸ’” If we wanted to use ES6 Modules in our script, we’d have to add type='module' to our script tag.

Adding final touches for mobile devices using custom JavaScript

Now that we have a place to put arbitrary client-side JavaScript, let’s use a trick – also known as ā€œa hacky workaroundā€ (oh, hello, welcome to web development) – to ensure our interface displays correctly even when the address bar is visible in mobile browsers.

Update your index.js file to match the following:

index.js

function fixInterfaceSizeOnMobile () {
  function resetHeight() {
    let vh = window.innerHeight * 0.01;
    document.querySelector(':root').style.setProperty('--vh', `${vh}px`)
  }

  resetHeight()

  window.onresize = function (_event) {
    resetHeight()
  }
}

function onLoad () {
  fixInterfaceSizeOnMobile()

  // For debugging: log out htmx events to the console.
  htmx.logger = function(elt, event, data) {
    if(console) {
      console.info(event, elt, data)
    }
  }
}

window.addEventListener('load', onLoad)

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

Focus is hard sometimes

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

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

<form id='message-form' ws-send>
  …
  <input 
    id='text' name='text' type='text' required
    @htmx:ws-after-send.window='
      $el.value = ""
      $el.focus()
    '
  />
  …
</form>

šŸ’” Notice that we’re listening for the event a little differently here. Instead of listening for the @htmx:ws-after-send event on the <input> element itself, we’re using the .window modifier to listen for it on the window. That’s because the event is dispatched from the <form> element that contains the ws-send attribute. And that element is our parent so events dispatched from there will not bubble down to us (events in JavaScript bubble up). But the event will bubble up from there to the window so that’s where we listen for it.

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

That’s nice!

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

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

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

šŸ’” Notice how we declared the function in global scope so we can call it from Alpine.js.

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

<form id='message-form' ws-send>
  …
  <input id='text' name='text' type='text' required
    @htmx:ws-after-send.window='
      $el.value = ""

      // On mobile we don’t refocus the input box
      // in case there is a virtual keyboard so
      // the person can see the message they just sent.
      if (!isMobile()) { $el.focus() }
    '
  >
  …
</form>

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

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

Persisted Kitten Chat

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

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

JavaScript Database (JSDB) to the rescue once again!

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

Message.component.js

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

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

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

chat.socket.js

import Message from './Message.component'

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

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

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

    // We don’t need to use the message HEADERS, delete them so
    // they don’t take unnecessary space when we persist the message.
    delete message.HEADERS

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

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

šŸ’” Messages sent by the htmx WebSocket extension include a htmx-specific HEADERS object.

For example:

{
  nickname: 'Aral',
  text: 'Hello, everyone!',
  HEADERS: {
    'HX-Request': 'true',
    'HX-Trigger': 'message-form',
    'HX-Trigger-Name': null,
    'HX-Target': 'chat',
    'HX-Current-URL': 'https://localhost/'
  }
}

All we want to store in the database is the nickname 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.js

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

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

export default () => kitten.html`
  …
  <div 
    id='chat'
    hx-ext='ws'
    ws-connect='/chat.socket'
    x-data='{ showPlaceholder: true }'
  >
    <ul id='messages' @htmx:load='
      showPlaceholder = false
      $el.scrollTop = $el.scrollHeight
    '>
      <if ${kitten.db.messages.length === 0}>
        <then>
          <li id='placeholder' x-show='showPlaceholder'>
            No messages yet, why not send one?
          </li>
        <else>
          ${kitten.db.messages.map(message => kitten.html`<${Message} message=${message} />`)}
      </if>
    </ul>
    …
  </div>
`

šŸ’” Since the conditional logic in our template is somewhat verbose, I chose to use Kitten’s <if> conditional. The thing to be aware of here is that, due to JavaScript’s language limitations, the <if> conditional cannot short circuit (so every branch of the conditional is evaluated, even if it is false, even if only the the true branch is displayed). This means that if you try to access a property on an object that is null or undefined in the falsey branch, your app will return an error. In this case, it is not a problem but, if it was, you would either use chained optionals in all your branches or one of the following JavaScript-only conditional methods.

Here’s a separate example to show how chained optionals would work:

let ok = false

const a = {}
if (ok) a.b = []

html`
  <if ${ok}>
    <ul>
      ${a.b?.map(c => `<li>${c}</li>`)}
    </ul>
  <else>
    <p>Sad trombone.</p>
  </if>
`

And here are the three different ways you can implement conditional logic in your templates using regular JavaScript:

The first one, which resembles Kitten’s <if> syntax the most, is to use an immediately-invoked closure:

${(messages => {
  if (messages.length === 0) {
    return kitten.html`
      <li id='placeholder' x-show='showPlaceholder'>
        No messages yet, why not send one?
      </li>
    `
  } else {
    return messages.map(message => kitten.html`<${Message} message=${message} />`)
  }
})(kitten.db.messages)}

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

${(function (messages) {
  if (messages.length === 0) {
    return kitten.html`
      <li id='placeholder' x-show='showPlaceholder'>
        No messages yet, why not send one?
      </li>
    `
  } else {
    return messages.map(message => kitten.html`<${Message} message=${message} />`)
  }
})(kitten.db.messages)}

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

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

All three of these approaches are equivalent and, since they use native JavaScript statements, all of them short circuit. Feel free to use the one that reads best for you.

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

I guess persistence really does pay off.

(I’m here all week. 🐱)

Project-specific secret

Kitten automatically generates a cryptographically-secure secret for each project.

For the cryptographers among you, this is a base256-encoded ed25519 private key. For everyone else, it’s a lovely string of 32 emoji that looks something like this:

šŸŒ»šŸ¦šŸ°šŸØšŸ®šŸŒ¼šŸ™šŸ¦ƒšŸ¦ššŸ¦ˆšŸŖ“šŸ†šŸ™‰šŸ˜šŸŒ®šŸŒšŸ§‡šŸŠšŸ¦šŸ‚šŸ§šŸØšŸšŸ™šŸ§šŸ„œšŸµšŸ®šŸ„”šŸ¦˜šŸ“šŸ»

This secret is shown to you only the very first time you run Kitten on a given project (folder).

If you’re wondering how in the world you are going to type that in, don’t worry: you’re not supposed to be able to by design.

Instead, please add this secret to your password manager of choice.

If you implement authenticated routes in your application, you can use your password manager to enter your secret for you.

For technical details, please see the Cryptographical Properties section.

Authenticated Routes

To signal to Kitten that a route is only available when the person is authenticated, you add šŸ”’ to the end of the route name (that’s a lock emoji).

Kitten itself has just a route that’s available to all apps/sites created with Kitten at:

settingsšŸ”’/

If you hit the /settings route on any Kitten app/site, you’ll be automatically redirected to the /sign-in route if you’re not authenticated and you only see the settings section if you are.

Adding the suffix to a directory, as shown here, ensures that it applies to all routes in that directory.

You can also add it to specific routes (files).

HTTP Routes

We’ve seen examples of simple Kitten apps that use pages and WebSockets, but what if you want to POST data from a web form, implement Ajax with fragments of HTML using htmx, or create an Application Programming Interface (API) that returns JSON?

Enter HTTP Routes.

(OK, technically speaking, everything is an HTTP route but that’s the terminology we use in Kitten to separate Pages from, well, every other HTTP route except WebSocket routes.)

Similar to how you create pages in .page.js files and WebSocket routes in .socket.js files, HTTP routes are declared using a naming convention based on their filename extension which can be any valid HTTP1/1.1 method in lowercase (e.g., .get.js, .post.js, .patch.js, .head.js, etc.)

HTTP Routes do not carry out any processing on whatever value you return for them.

So you can return a fragment of HTML (e.g., a component) if you’re implementing Ajax, or use JSON.stringfy() to return a JSON repsonse, etc.

e.g.,

my-project
  ā”œ index.page.js
  ā”œ index.post.js
  ā”œ about
  │   ā•° index.page.js
  ā”œ todos
  │   ā•° index.get.js
  ā•° chat
     ā•° index.socket.js

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

e.g.,

my-project
  ā”œ src
  │  ā”œ index.page.js
  │  ā”œ index.post.js
  │  ā”œ index.socket.js
  │  ā•° about
  │      ā•° index.page.js
  ā”œ test
  │   ā•° index.js
  ā•° README.md

POST/redirect/GET

One very common HTTP Route is POST, usually used when you want to send data back from a page and persist it.

The Guestbook example (examples/guestbook), demonstrates this pattern in the simplest possible way.

First, let’s create a page that will display the form for signing the guestbook and existing guestbook entries:

index.page.js

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

export default () => kitten.html`
  <h1>Guestbook</h1>

  <h2>Sign</h2>

  <form method='POST' action='/sign'>
    <label for='message'>Message</label>
    <textarea id='message' name='message' required></textarea>
    <label for='name'>Name</label>
    <input type='text' id='name' name='name' required />
    <button type='submit'>Sign</button>
  </form>

  <h2>Entries</h2>

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

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

  <style>
    body { font-family: sans-serif; margin-left: auto; margin-right: auto; max-width: 20em; }
    label { display: block; }
    textarea, input[type='text'] { width: 100%; }
    textarea { height: 10em; }
    button { width: 100%; height: 2em; margin-top: 1em; font-size: 1em; }
    ul { list-style-type: none; padding: 0; }
    li { border-top: 1px dashed #999; }
    .message, .nameAndDate { font-family: cursive; }
    .message { font-size: 1.5em; }
    .nameAndDate { font-size: 1.25em; font-style: italic; text-align: right; }
  </style>
`

This is very straightforward. Notice that we have a form for signing the guestbook and it’s just plain HTML.

<form method='POST' action='/sign'>
  <label for='message'>Message</label>
  <textarea id='message' name='message' required></textarea>
  <label for='name'>Name</label>
  <input type='text' id='name' name='name' required />
  <button type='submit'>Sign</button>
</form>

Its method is set to POST and its action is /sign. That means that when the submit button is pressed, it will carry out an HTTP POST request to the /sign route on our server.

In that route, we will save the new guestbook entry and then redirect the person’s browser back to the index page. This pattern of handling a POST request and then redirecting to a GET route (our pages are all GET routes), is called the POST/redirect/GET pattern.

So let’s create our POST route:

sign.post.js

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

export default ({ request, response }) => {
  // Basic validation.
  if (!request.body || !request.body.message || !request.body.name) {
    return response.forbidden()
  }

  kitten.db.entries.push({
    message: request.body.message,
    name: request.body.name,
    date: Date.now()
  })

  response.get('/')
}

And that’s it.

šŸ’” Kitten has a number of request and response helpers defined to make your life easier. You just used two of them, above: response.forbidden(), which returns a HTTP 403: Forbidden error and response.get(), which is an alias for response.seeOther(), which returns and HTTP 303: See Other response.

Additionally, Kitten also has response helpers for returning JSON (request.json()) and a JSON file that triggers the download mechanism in a browser (request.jsonFile()).

In this case, since we’re going a Post/Redirect/Get (PRG), using the get() alias makes the intent of our code clearer.

You could also have manually handled the direction like this:

response.statusCode = 303
response.setHeader('Location', '/')
response.end()

(Which is exactly what Kitten does internally when you use the .get() / .seeOther() methods.)

In addition to the methods you’ve already seen, Kitten also supports the following helpers:

Request

is (array|string): returns true/false based on whether the content-type of the request matches the string or array of strings presented.

This is used internally for Express Busboy compatibility but you might find it useful in your apps too if you’re doing low-level request handling.

Response

get (location), seeOther (location): 303 See Other redirect (always uses GET).

redirect (location), temporaryRedirect (location)`: 307 Temporary Redirect (does not change the request method).

permanentRedirect (location): 308 Permanent Redirect (does not change the request method)

badRequest (body?): 400 Bad Request.

unathenticated (body?), unauthorised (body?), unauthorized (body?): 401 Unauthorized (unauthenticated).

forbidden (body?): 403 Forbidden (request is authenticated but lacks sufficient rights – i.e., authorisation – to access the resource).

notFound (body?): 404 Not Found.

error (body?), internalServerError (body?): 500 Internal Server Error.

Run kitten command on your project folder and visit https://localhost to see your guestbook.

šŸ’” Notice that we’re doing some very basic validation to make sure that body of the request (which is where the form’s data is found) is as we expect it.

In case you’re worried about script inject, type <script>alert("Hehe, I just hacked you!")</script> in your message box. Try it out and see what happens. Kitten’s template engine automatically escapes interpolated string content to avoid such attacks. If you wanted to allow HTML through, Kitten provides a global kitten.safelyAddHtml() function you can call that sanisitises the input before allowing it. While it comes with intelligent defaults, you can also customise exactly what you want to let through or not.

You can test out the basic server-side validation using a basic curl command. First, let’s send a bad request and see what we get. In this case, we’re not sending any data at all:

curl --include --data-urlencode '' https://localhost/sign/

And we see that our validation works:

HTTP/1.1 403 Forbidden
Access-Control-Allow-Origin: *
Set-Cookie: sessionId=LbhwT0YqkVyAzQR0S-2KFGPa; Max-Age=28800000; Path=/; HttpOnly; Secure; SameSite=Strict
Date: Fri, 11 Aug 2023 12:36:23 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 0

(The --include flag is what tells curl to print out the response header we received.)

Finally, let’s send a valid request and sign the guestbook from the command-line like proper nerds:

curl --include --data-urlencode 'message=From curl with love.' --data-urlencode 'name=Curl' https://localhost/sign/

This time, we get a much nicer response:

HTTP/1.1 303 See Other
Access-Control-Allow-Origin: *
Set-Cookie: sessionId=LMw_wcABaqRNWyhYmF7fvHFJ; Max-Age=28800000; Path=/; HttpOnly; Secure; SameSite=Strict
Location: /
Date: Fri, 11 Aug 2023 12:37:35 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 0

It’s telling us that we should see the / route. So, let’s. Go back to your browser and refresh the main page and you should see the guestbook entry from curl.

Multipart forms and file uploads

Kitten has high-level support for multi-part form handling and file uploads.

Uploads sent to POST routes via <input type='file'> in your pages are automatically saved in your project’s uploads folder. Kitten automatically assigns them unique IDs and serves them from the /uploads/<unique-id> route. The Upload objects are also available to your POST routes in the request.uploads array.

šŸ’” An upload object has the following properties:

.id           // Unique id. Used to look up uploads and calculate resource paths.
.fileName     // Name of the original file that was uploaded.
.filePath     // Absolute path to uploaded file on server.

.resourcePath // Relative URL resource path the upload can be downloaded from. 

.mimetype     // MIME type of file.
.field        // Name of file upload field in form that file was uploaded from.
.encoding     // Encoding of file.
.truncated    // Whether file was truncated or not (boolean).
.done         // Whether upload was successfully completed or not (boolean).

And the following method:

.delete()     // Deletes the upload.

A common idiom is to save the upload’s unique ID (e.g., request.uploads[0].id), along with any other data in your form (e.g., the alt-text of an image upload), in your own database tables. Then, when you want to, say, render an uploaded image on a page, you can use the global kitten.upoads object to reference the upload you need and access its resource path.

e.g.,

kitten.html`
  <img src='${kitten.uploads.get(uploadId).resourcePath}' alt='…'>
`

šŸ’” The kitten.uploads collection has the following methods:

.get(id)    // Returns Upload object with given ID (or undefined, if it doesn’t exist).
.all()      // Returns array of all Upload objects.
.allIds()   // Returns array of strings of all Upload object IDs.
.delete(id) // Deletes object with given id (or fails silently if it doesn’t exist).

Kitten can handle multiple file uploads as well as single ones.

šŸ’”Note that you must set the enctype='multipart/form-data' attribute on your forms for file uploads to work correctly.

The following basic example shows just how easy it is to handle file uploads in Kitten. In it, you can upload one image at a time along with its alt-text and displays them in a grid at the top of the page:

index.post.js

export default function ({ request, response }) {
 request.upoads.forEach(upload => {
   kitten.db.images.push({
     path: upload.resourcePath,
     altText: request.body.altText ? request.body.altText : upload.fileName
   })
 })
 response.get('/')
}

index.page.js

if (!kitten.db.images) kitten.db.images = []

export default () => kitten.html`
<h2>Uploaded images</h2>

<if ${kitten.db.images.length === 0}><p>None yet.</p></if>

<ul>
  ${kitten.db.images.map(image => kitten.html`
    <img src=${image.path} alt=${image.altText}>
  `)}
</ul>

<h2>Upload an image</h2>

<form method='post' enctype='multipart/form-data'>
  <label for='image'>Image</label>
  <input type='file' name='image' accept='image/*'>
  <label for='alt-text'>Alt text</label>
  <input type='text' id='alt-text' name='altText'>
  <button type='submit'>Upload</button>
</form>

<style>
  body { max-width: 640px; margin: 0 auto; padding: 1em; font-family: sans-serif; }
  ul { padding: 0; display: grid; grid-template-columns: 1fr 1fr; }
  img { max-height: 30vh; margin: 1em; }
  input { width: 100%; margin: 1em 0; }
  button { padding: 0.25em 1em; display: block; margin: 0 auto; }
</style>
`

You can find the code for the above example in examples/file-uploads.

End-to-end encrypted Kitten Chat

So now you’ve learned how to use WebSockets, authenticated routes, HTTP routes, and how to carry out global tasks using a main.script.js file in Kitten. How about we put it all together and sprinkle some of Kitten’s built-in cryptography support to create an end-to-end encrypted version of the Kitten Chat example.

šŸ’” This is just a basic example of implementing end-to-end encryption. It is not meant to be used in production or in real world situations for private communication.

🚧 Some of the elements you see in this example (like the remote message emitter and the means of retrieving the public keys of remote servers and delivering messages to them) will be implemented in a production-ready manner in Kitten itself soon. Once this happens, I’ll either add a separate example that uses the built-in APIs or update this example to use them based on which I find more useful at the time.

Encryption and threat models

Nothing is entirely secure. Some things are secure enough.

In many situations, the best we can do is to raise the cost of surveillance to ensure that it is only used in specific cases (as opposed to mass surveillance).

End-to-end encryption is one of the means we have open to us for raising the cost of surveillance.

šŸ’” It’s important to understand your threat model.

Kitten’s security model does not protect you against targetted surveillance by determined adversaries that could compromise your server to install and run their own compromised version of your Kitten app.

Barring any potential vulnerabilities in Kitten, if you are hosting your app using a commercial web host, this means that they will have had to infiltrate your web host and install an app that can steal your secret. This would normally require either a determined person working at the web host or a state-level actor.

If you’re hosting your app on your own hardware at a physical location you control, it would require the compromise of that location.

What end-to-end encryption mainly protects against is the opportunistic person at your web host being able to read your messages even if they compromise your machine.

Messages are already protected in transit via TLS. Their being end-to-end encrypted means that they are also protected at rest in the database on the server.

Since your secret remains on the client (the browser), implementing end-to-end encryption requires the use of JavaScript.

So, in this example, we will be making use of htmx and Alpine.js’s event handling to encrypt and decrypt messages in the browser using JavaScript. The server will only ever see the encrypted text (or as we call it in cyptography, the ciphertext) and never the unencrypted text (or plaintext.)

We’ll start from where we left off in the Persisted Kitten Chat example.

Private vs public routes

In the Persisted Kitten Chat example, we hadn’t implemented authentication and all our routes were public. Anyone could join the chat and, if they wanted to, even bypass our web interface and connect directly to our chat socket.

Needless to say, that’s not something you’d normally implement outside of a simple example for a tutorial.

For our end-to-end encrypted chat example, we need to decide which routes we keep public and which ones must be private.

To begin with, make a copy of the Persisted Kitten Chat example and let’s create a directory that will require any route placed in it require authentication:

mkdir privatešŸ”’

šŸ’” We saw earlier that we can use the built-in authentication system in Kitten to easily create private routes by appending them with the lock emoji (šŸ”’).

Now, let’s copy all the files that were previously public into our new private folder.

If you were to run the example now and hit https://localhost, you’d get the 404 Kitten since we no longer have an index.page.js in the root of our project (everything is in the private folder). So let’s add a simple index.html file in the root of our project with a link that takes us to the chat. This is a page anyone will be able to access:

index.html

<h1>Public site</h1>

<a href='/private'>End-to-end encrypted Kitten chat</a>

šŸ’” Notice how we didn’t have to add the lock emoji in the link. Kitten is clever enough to strip it off when creating its route patterns.

Now, we’re going to run Kitten a little differently, by explicitly specifying the domain instead of using localhost:

kitten --domain=place1.localhost

We’re doing this because to test the end-to-end encrypted Kitten chat, we will need to fire up two different instances of the Kitten server so we can use them to chat. Since we’re testing locally, we need to use a subdomain that is an alias for localhost but will be treated as a different domain by browsers (this is to ensure that cookies are isolated between the instances as cookies are not isolated by port but by domain).

Kitten has built-in support for four localhost aliases to help you test the peer-to-peer features of Small Web apps (place1.localhost to place4.localhost).

šŸ’” Subdomains on localhost Just Work ā„¢ on Linux but on macOS you have to manually edit your /etc/hosts file to map the subdomains to 127.0.0.1.

Once you’ve made the changes, your hosts file should resemble the one below:

##
# Host Database
#
# localhost is used to configure the loopback interface
# when the system is booting.  Do not change this entry.
##
127.0.0.1	localhost place1.localhost place2.localhost
255.255.255.255	broadcasthost
::1             localhost

Now, hit https://place1.localhost and you should see the link to the private chat section.

šŸ’” When the server starts, Kitten will generate a new secret for you. Note this down in your password manager now.

Follow the link on the page and you should reach Kitten’s automatically-generated Sign In page at /sign-in.

Enter the secret you had saved in your password manager to sign in.

šŸ’” Did you forget to note it down in your password manager? That’s OK. If you ever forget the secret for a project you’re testing locally, you can follow the ā€œDatabasesā€ link that Kitten displays when it’s run and delete the folder that holds the database for your project. The next time you run Kitten, it will recreate the password. Don’t forget to note it down this time :)

You should now see the chat interface from before and the chat should function exactly as the Persisted Kitten Chat example did.

Now, let’s change it so our messages are end-to-end encrypted.

privatešŸ”’/index.page.js

First, let’s update our interface based on how we envision the chat to work when end-to-end encryption is implemented.

Starting with the interface and working inwards from there is what’s known as ā€œoutside-in design.ā€ It lets us concentrate on how our tool is going to be used first before we get bogged down on the implementation details.

The first change we’re going to make is to the <div> tag where the htmx socket is defined.

There, we’re going to have htmx prevent the wsConfigSend event and call our encryptMessage() handler so we can encrypt the message before it is sent. (We will then manually send the message ourselves.)

<div 
  id='chat'
  hx-ext='ws'
  ws-connect='/private/chat.socket'
  x-data='${kitten.js`{ showPlaceholder: true }`}'
  @htmx:ws-config-send.prevent='encryptMessage'
>

The only other change we’re going to make is to change the names and labels of the form elements.

Since our app will now enable people at different domains to chat to each other securely, we need to specify which domain we are sending our message to. And, finally, we rename the message itself from text to plainText to make it very clear that this is the message before it is encrypted.

<form id='message-form' ws-send >
  <label for='domain'>To:</label>
  <input id='domain' name='domain' type='text' required />

  <label for='plainText'>Message:</label>
  <input id='plainText' name='plainText' type='text' required
    …
  />
  …
</form>

The rest of the page stays the same.

We’ll look at how we implement the encryptMessage() function later but first, we’ve only specified when we should encrypt messages. We haven’t specified when we should decrypt them.

So let’s think about that next.

privatešŸ”’/Message.component.js

We mentioned earlier that the server only ever sees the ciphertext and never the plaintext. So we know that both encryption and decryption must take place on the client (in the browser) using client-side JavaScript.

We also know that, given how htmx works, the chat socket sends new messages as HTML snippets to the client.

Finally, we know that if we want to run custom JavaScript, we can use Alpine.js to do so declaratively.

So, let’s combine all this and take a look at how we must modify the Message.component so that messages are automatically decrypted as they load in the browser:

export default function Message ({ message }) {
  return kitten.html`
    <li
      x-data='{
        messageText: "${kitten.sanitise(message.cipherText)}"
      }'
      x-init='$nextTick(() => messageText = decryptMessage("${kitten.sanitise(message.cipherText)}", "${kitten.sanitise(message.from)}", "${kitten.sanitise(message.to)}"))'
    >
      <strong>${kitten.sanitise(message.from)} → ${kitten.sanitise(message.to)}</strong> <span x-text='messageText'>${kitten.sanitise(message.cipherText)}</span>
    </li>
  `
}

What we’re doing here is using Alpine.js’s x-data directive to set up our data model for the list item node. In it, we populate a property called messageText with our message’s ciphertext.

Then, we write up the x-init directive so that when the node is initialised, we call a function called decryptMessage(), passing it our ciphertext as well the domains that are the sender and receiver of our message.

šŸ’” Notice that the x-init handler is wrapped in a call for Alpine.js’s magic $nextTick property. This makes Alpine.js wait until DOM updates are finished so we don’t call the decryption handler before it has had a chance to load.

So what’s happening here is that the ciphertext is sent from the server to the client and, before the message is displayed, it is decrypted on the client and the plaintext is shown in the interface.

Now, letā€˜s actually add the encryptMessage() and decryptMessage() function implementations to the client-side script imported by our page.

privatešŸ”’/index.js

Our functions will make use of the built-in cryptography functions in the Kitten Cryptography API. This is a library Kitten serves at runtime from the route /🐱/library/crypto-1.js:

import { encryptMessageForDomain, decryptMessageFromDomain } from '/🐱/library/crypto-1.js'

globalThis.encryptMessage = async function (event) {
  // Encrypt the plain text and send that in the message to the server.
  const parameters = event.detail.parameters
  const ourPrivateKey = localStorage.getItem('secret')
  const encryptedMessage = await encryptMessageForDomain(parameters.plainText, ourPrivateKey, parameters.domain)
  event.detail.socketWrapper.send(
    JSON.stringify({
      cipherText: encryptedMessage,
      from: window.location.host,
      to: parameters.domain
    }),
    event.detail.elt // elt = DOM element
  )
}

globalThis.decryptMessage = async function (cipherText, fromDomain, toDomain) {
  if (fromDomain === window.location.host) {
    // This is a message we sent. Since the shared secret is commutative (g^jk === g^kj, or, in other words,
    // can either be our private key and their public key or vice-versa), we just flip the from/to domains :)
    fromDomain = toDomain
  }
  const ourPrivateKey = localStorage.getItem('secret')
  return await decryptMessageFromDomain(cipherText, ourPrivateKey, fromDomain)
}

Encryption

Remember that the encryptMessage() function is called by htmx before a message is sent over the socket to the server. (Specifically, when the htmx:wsConfigSend event fires.)

Based on the htmx documentation, we know that the event argument will contain a detail property, which, itself, will contain a parameters object with the form data, an elt property that holds a reference to the DOM node that holds the socket, and a socketWrapper that has a send() method we can use to manually send the message after we’ve encrypted it.

So we:

  1. Retrieve our secret key from local storage (this was automatically saved there for us by Kitten when we signed in.)

  2. Call the encryptMessageForDomain() function we imported from Kitten’s cryptography library and pass it the plainText and domain from the form as well as our secret (or private key as it’s known in cryptography).

  3. Finally, manually create a JSON message that contains the encrypted text (cipherText) as well as the from and to properties that address the sender (our domain, which we get from window.location.host so it includes our port number, which is important when testing locally) and the receiver (the remote domain, which we manually enter into the Domain: textbox in the interface).

Decryption

Similarly, remember that the decryptMessage() function is called by Alpine.js’s x-init directive when a new Message component is received from the server via the WebSocket. And we saw in the Message.component that we pass in the ciphertext, sender, and receiver as arguments.

So all we do here is to retrieve our secret (private key) from local storage again, check to see if we’re the sender and, if so, take advantage of the associativity property of Diffie-Hellman shared secrets to swap the receiver and sender domains thereby eventually resulting in the decryption of the message using our own public key.

To understand that last bit more fully, let’s also take a look at the encryptMessageForDomain() and decryptMessageFromDomain() functions in the Kitten Cryptography API.

Kitten Cryptography API

The Kitten Cryptography API (which you can find in /src/lib/crypto.js) contains, among other things, high-level functions for encrypting and decrypting messages sent between Small Web domains:

const textDecoder = new TextDecoder() // default: 'utf-8'

export async function encryptMessageForDomain (message, ourPrivateKey, domain) {
  const sharedSecret = await sharedSecretForDomain(domain, ourPrivateKey)
  const encryptedMessageBytes = await encrypt(sharedSecret, message)
  const encryptedMessage = bytesToHex(encryptedMessageBytes)
  return encryptedMessage
}

export async function decryptMessageFromDomain (encryptedMessage, ourPrivateKey, domain) {
  const sharedSecret = await sharedSecretForDomain(domain, ourPrivateKey)
  const decryptedMessageUInt8Array = await decrypt(sharedSecret, hexToBytes(encryptedMessage))
  const decryptedMessageUtf8String = textDecoder.decode(decryptedMessageUInt8Array)
  return decryptedMessageUtf8String
}

Both these methods are fairly spartan and rely on the sharedSecretForDomain() function to carry out the heavy lifting:

// Cache shared secrets for different domains as they’re
// expensive to calculate.
const sharedSecrets = {}

async function sharedSecretForDomain (domain, ourPrivateKey) {
  if (sharedSecrets[domain] === undefined) {
    // We don’t have a shared secret yet. Attempt to calculate one
    // by getting the other domain’s ed25519 public key.
    const domainToContact = `https://${domain}/šŸ’•/id`
    const theirPublicKeyResponse = await fetch(domainToContact)
    const theirPublicKeyHex = await theirPublicKeyResponse.text()
    const ourPrivateKeyHex = bytesToHex(emojiStringToSecret(ourPrivateKey))
    sharedSecrets[domain] = await getSharedSecret(ourPrivateKeyHex, theirPublicKeyHex)
  }
  return sharedSecrets[domain]
}

The sharedSecretForDomain() function is where the Diffie-Hellman key exchange happens and the shared secret that’s used to encrypt and decrypt messages between a pair of Small Web domains is calculated.

As part of the Small Web Protocol, every Small Web site serves its ed25519 public key at the /šŸ’•/id route.

šŸ’” The Small Web Protocol requires Small Web routes to be namespaced under the šŸ’• path. This also happens to be the Small Web logo.

So the first thing we do is get this for the domain we want to send the message to.

Then we combine that with out private key to calculate the shared secret.

The actual calculation of the shared secret uses the getSharedSecret() function from Paul Miller’s noble-ed25519 library.

šŸ’” The Kitten Cryptography API also makes extensive use of Paul’s ed25519-keygen library.

Once we have the shared secret, the actual encryption and decryption are handled by the encrypt() and decrypt() functions from Paul’s micro-aes-gcm library, which itself uses low-level cryptographic primitives from the Web Crypto API.

šŸ’” AES-GCM stands for Advanced Encryption Standard Galois/Counter Mode but all you really need to know is that Paul and the folks who implemented the Web Crypto API in your browser have done the hard work of implementing the cryptography and all you need to do is to use the high-level encryptMessageForDomain() and decryptMessageFromDomain() functions exposed by the Kitten Cryptography API when creating end-to-end-encrypted messages to send between peer-to-peer Small Web places.

Also note that while the Kitten Cryptography API is available on both the server and the client, the encryption and decryption functions are only meant to be run in the browser. In fact, under the current Kitten runtime (Node version 18 LTS), they will cause a runtime error as the global crypto object that exposes the Web Cryptography API is not present. It can be easily polyfilled but isn’t on purpose as you shouldn’t be using it. When Kitten moves onto a runtime that’s version 19+, the calls won’t fail but you still shouldn’t be using them on the server.

At this point, we have made all the changes we need to on the client side but we still need to handle messages differently on the server. So, next, let’s take a look at the server-side code.

/privatešŸ”’/chat.socket.js

import Message from './Message.component.js'

if (kitten.db.messages === undefined) kitten.db.messages = [];

function messageHtml (message) {
  // Wrap the same Message component we use in the page
  // with a node instructing htmx to add this node to
  // the end of the messages list on the page.
  return kitten.html`
    <div hx-swap-oob="beforeend:#messages">
      <${Message} message=${message} />
    </div>
  `
}

// Kitten Chat example back-end.
export default function ({ socket, request }) {
  const saveAndBroadcastMessage = message => {
    // Persist the message in the messages table.
    kitten.db.messages.push(message)

    // Since we are not optimistically showing messages
    // as sent on the client, we send to all() local clients, including
    // the one that sent the message. If we were optimistically
    // updating the messages list, we would use the broadcast()
    // method on the socket instead.
    const numberOfRecipients = socket.all(messageHtml(message))

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

  // Handle remote messages.
  const remoteMessageHandler = message => {
    if (!isValidMessage(message)) {
      console.warn(`Message from remote place is invalid; not saving or broadcasting to local place.`)
      return
    }
    saveAndBroadcastMessage(message)
  }
  kitten.events.on('message', remoteMessageHandler)

  socket.addEventListener('close', () => {
    // Housekeeping: stop listening for remote message events
    // when the socket is closed so we don’t end up processing
    // them multiple times when the client disconnects/reconnects.
    kitten.events.off('message', remoteMessageHandler)
  })

  socket.addEventListener('message', async event => {
    // A new message has been received from a client connected to
    // our own Small Web place: broadcast it to all clients
    // in the same room and deliver it to the remote Small Web place
    // it is being sent to after performing basic validation.

    const message = JSON.parse(event.data)

    if (!isValidMessage(message)) {
      console.warn(`Message from local place is invalid; not saving or delivering to remote place.`)
      return
    }

    // We don’t need to use the message HEADERS, delete them so
    // they don’t take unnecessary space when we persist the message.
    delete message.HEADERS

    saveAndBroadcastMessage(message)

    // Deliver message to remote place’s inbox.
    // Note: we are not doing any error handling here.
    // In a real-world application, we would be and we’d be
    // alerting the person if their message couldn’t be 
    // delivered, etc., and giving them the option to retry.

    const body = JSON.stringify(message)
    const requestOptions = {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body 
    }

    const inbox = `https://${message.to}/inbox`
    try {
      await fetch(inbox, requestOptions)
    } catch (error) {
      console.error(`Could not deliver message to ${message.to}`, error)
    }
  })
}

// Some basic validation.
// …

There are two major changes here so let’s examine them separately.

  1. When we receive a message from a local client, we must not only save it in the database but also deliver it to the remote node.

  2. We must also have a way of knowing when a message has been delivered to us so we can both save it in our database and broadcast it to all connected local clients.

We handle the first requirement by making a POST call to the remote server’s /inbox route:

const body = JSON.stringify(message)
const requestOptions = {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body 
}

const inbox = `https://${message.to}/inbox`
try {
  await fetch(inbox, requestOptions)
} catch (error) {
  console.error(`Could not deliver message to ${message.to}`, error)
}

We will see what the /inbox route looks like when we implement it next.

But for now, let’s also look at how we statisfy the second requirement:

// Handle remote messages.
const remoteMessageHandler = message => {
  if (!isValidMessage(message)) {
    console.warn(`Message from remote place is invalid; not saving or broadcasting to local place.`)
    return
  }
  saveAndBroadcastMessage(message)
}

kitten.events.on('message', remoteMessageHandler)

socket.addEventListener('close', () => {
  // Housekeeping: stop listening for remote message events
  // when the socket is closed so we don’t end up processing
  // them multiple times when the client disconnects/reconnects.
  kitten.events.off('message', remoteMessageHandler)
})

Here we listen tomessage events on a global Kitten object called Events that we haven’t seen before.

Events is just global instance of Node’s EventEmitter that’s made available to you for ease of authoring.

When we hear that a message has been received, we save it before broadcasting it to all local clients. And we make sure we stop listening for the event when the socket is closed so that we don’t handle messages multiple times in case clients disconnect and reconnect.

At this point, we have just one thing left to create: the /inbox route where other places can send us messages.

So let’s build that now.

/inbox.post.js

The /inbox route, as we saw earlier in the code that sends messages to it, is a POST route. Also notice that it is not in our private directory. Any other Small Web place should be able to send a message to our inbox so it must be public.

The code for it couldnā€˜t be simpler:

export default function ({ request, response }) {
  const message = request.body
  console.info('šŸ“„ /inbox received:', message)
  kitten.events.emit('message', message)
  response.end('ok')
}

All we’re doing is getting the message from the body of the request (which Kitten automatically parses from JSON for us) and then using Kitten’s convenient global EventEmitter instance, Events, to dispatch a message event.

šŸ’” Again, this is a basic example. In a real-world scenario, you would carry out further validation on received message.

Testing peer-to-peer Small Web features

To test the end-to-end encrypted Kitten chat example, you have to run more than one instance of it. So open two Terminal windows (or tabs or panes within your Terminal app) and run two instances of the example:

Terminal 1

kitten --domain=place1.localhost

Terminal 2

kitten --domain=place2.localhost --port=444

šŸ’” Notice that you have to use not just a different domain but, when testing locally, also a different port.

Now, open https://place1.localhost/private in one browser window and https://place2.localhost:444/private in another.

In each one, enter the address of the other in the Domain: field (without the https:// prefix) and send a few messages between them.

Remember that when running in deployment from a domain name, these places would be owned by different people and reside on different computers around the world.

šŸ’” If you want a little respite from all the cryptography stuff and give yourself a little reward, check out the Animated End-to-End Encrypted Kitten Chat project in the examples folder ;)

Wow, that was the longest tutorial yet! And guess what?…

šŸŽ‰ Congratulations!

You’ve reached the end of the tutorials section!

Hope this has given you an idea of what’s possible with Kitten and inspired you to continue playing with it. These tutorials will constantly be iterated upon and new ones added, so do check back from time to time.

To continue learning about every little detail of Kitten, check out the Kitten Reference.

Like this? Fund us!

Small Technology Foundation is a tiny, independent not-for-profit.

We exist in part thanks to patronage by people like you. If you share our vision and want to support our work, please become a patron or donate to us today and help us continue to exist.