Kitten

Technical Manual

Return to main Reference page.

🚧 In development. 🚧

This is a complete walkthrough and reference of how Kitten works from a technical perspective. Think of its as the equivalent of those old-school factory service manuals you would get for cars and even computers, back in the day.

One of the goals for the Technical Manual is to help anyone who wants to help improve Kitten to quickly get a conceptual model of how it works.

This manual is not necessary if all you want to do is to use Kitten to build Small Web apps and sites. For that, read the general reference guide and follow the tutorials. However, if you do decide to work your way through this manual, you will get a deeper understanding of how Kitten works under the hood.

šŸ’” The Technical Manual is not meant to be a line-by-line explanation of the code but more of a summary, although it will go into detail on the important bits.

While important third-party modules will be mentioned, for a full list of modules used and credits, please see the credits page.

The credits page is a combination of a manually-authored special thanks section and a package contributors section that is automatically generated by the contributors script on every build.

Installer

Screenshot of the Kitten installation script running in a terminal window. Heading: 🐱 Installing Kitten… • Web install detected. • Installing latest Kitten distribution. • Runtime v22.11.0 already exists, not updating. Installed. Following this, there are additional macOS instructions shown for updating your system’s path to add the location of the kitten binary under zsh and Fish shell and adding localhost aliases to your hosts file. Output ends with kitten [path to serve] and the terminal prompt following the script’s exit.
The installer being run via a web install (being piped into Bash).

Kitten’s installer is a single Bash script (install) that has customised behaviour depending on whether it is:

šŸ’” Kitten (and its installer) is currently supported on Linux, macOS, and Windows. However, support for Windows might be removed in the future given the direction that OS has gone which is incompatible with the ethical principles that underlie Kitten. For greater context, Kitten is also not supported Chromebooks.

šŸ’” The installer script requires Bash version 5 or later to run. macOS, specifically, ships an ancient version of Bash that must be updated before the installer can be run. The installer will detect if your version of Bash is too old, provide instructions on how to upgrade your version of Bash, and refuse to run if so.

How it works

Flowchart visualisation of installer flow as detailed in text below.

  1. Download and install the Kitten runtime, if necessary.

    The Kitten runtime is currently the latest available Node.js LTS release. By managing its own runtime, Kitten does not require Node.js to be installed on the machine and sidesteps versioning issues.

    The installer uses either curl or wget to download the runtime binary. If neither are available, it refuses to continue.

    šŸ’” The web-based single-line installation command defaults to curl for macOS and wget for Linux. The former is guaranteed to ship with macOS. On Linux, there is no such guarantee so it’s a best effort.

  2. Decide how to install Kitten.

    a. If the script is being run interactively (i.e., during development), the build script is run to build Kitten from source. Also, if the --npm flag is provided, the installer runs npm install to install dependencies.

    šŸ’” If you pass a positional argument to the installer script, it will be interpreted as the local path of a Kitten package file that you want to install. A Kitten package file is what is served from kittens.small-web.org for use when Kitten is being installed via the single-line installation command from the Web.

    This feature lets you test these package files locally.

    šŸ’” Note that the npm being run is Kitten’s own npm from the runtime it downloaded in Step 1. A system-wide Node.js installation is not required to build Kitten from source or even to develop with Kitten.

    You can run Kitten’s version of Node using the kitten-node command. Similarly, you can run Kitten’s version of npm using kitten-npm. This will ensure you’re running the exact same versions that Kitten does when you invoke the kitten command.

    b. If the script is being run from the web-based single-installation command (by being piped to Bash), then the build step is skipped. Instead, the requested Kitten package file for the requested Kitten version is downloaded and installed from the Kitten deployment site (source)

  3. On Linux, check if privileged ports are enabled and disable them if so (so Kitten can run on ports 443 and 80).

    šŸ”’ This requires sudo access and will prompt you for your password.

    šŸ’” Privileged ports is a concept that dates back to mainframe computers. It made sense back then, it doesn’t today. Linux is the only platform that holds onto this archaic concept.

    One of the design principles behind Kitten is that Kitten should run the same regardless of whether you’re in a development or deployment environment without requiring the use of heavyweight technologies like Docker. So Kitten running with TLS locally and defaulting to ports 443 and 80 (for redirections) and behaving exactly is it does in deployment is a feature, not a bug.

    Furthermore, Kitten is designed to run one site/app on one machine (VPS, single-board computer, etc.) Running multiple sites/apps on one Kitten install or running Kitten under Docket, etc., in deployment is not a planned or supported feature. Again, this is to keep things as simple as possible and make the deployment process (e.g., using Domain) as quick and seamless as possible to enable everyday people who use technology as an everyday thing to get started on the Small Web as easily and effortlessly as possible.

  4. Provide platform-specific post-installation instructions, if necessary:

    i. Add ~/.local/bin to the system path (if it isn’t already there)to enable use of the kitten command.

    ii. On macOS, to add localhost aliases for place1.localhost, place2.localhost, etc., to the hosts file. (These aliases are used when testing peer-to-peer Small Web apps locally during development.)

  5. Show the syntax for running the kitten command and exit.

Kitten ā€œbinaryā€

Screenshot of output of kitten command in terminal. An image banner of a cute minimalist grey cat with pink ears and nose sitting on a green hill in front of a clear blue sky is followed by credits (Kitten by Aral Balkan, Small Technology Foundation), version information, a link to the funding page, and links for tutorials, reference, issues, as well as the domain being served (https://localhost), the source (šŸ /demo), and the app’s data folder. Callouts prompt you to visit a link to create your identity and to press s to launch and interactive shell. Finally, it informs you that the server is running and listening for connections.
Screenshot of kitten server launch.

The kitten ā€œbinaryā€ is a bash script installed at ~/.local/bin/kitten that runs the main process using the Kitten runtime with loader customisations.

Specifically, it:

  1. Changes the working directory from the folder the kitten command was run in to the Kitten distribution folder

  2. Decides whether to run the Kitten Process Manager or the Kitten bundle directly based on whether it is being run in development mode (default) or production mode (with the environment variable PRODUCTION=true).

    šŸ’” If the FLAME_GRAPH=true environmental variable is set, the Kitten bundle is run directly as if in production mode due to the 0x flame graph module not being compatible with Node’s cluster module as used by the Kitten Process Manager.

  3. Launches Kitten’s runtime (the latest Node.js LTS binary as downloaded by the Kitten installer) to run the main process using the EcmaScript Module loading customisations found in the loader script, setting:

    • QUIET=true to disable console output from some of the modules used by Kitten (like Auto Encrypt).

    • NODE_OPTIONS='--require=./suppress-experimental.cjs' to load in code to turn off warnings for the experimental Node features Kitten uses so as not to pollute the tool’s output.

    • 0x -0 -- if the FLAME_GRAPH=true environment variable was set.

    • --inspect if the INSPECT=true environment variable is set.

    • Kitten’s --working-directory to the directory that the kitten command was run from.

Main process

The main process is different based on whether Kitten is running in development or production:

Kitten Process Manager

The Kitten Process Manager runs the Kitten bundle in a separate process using the Node cluster module to automatically restart it on restart requests (e.g., when the source code of the app/site being served changes).

Specifically, it:

  1. Creates a new Kitten process for the Kitten bundle.
  2. Listens for the exit event on the process.
  3. If the exit code is 99, which is Kitten’s code for a restart request, goes back to 1.
  4. For any other exit code, quits.

šŸ’” Basically, Kitten Process Manager does what we would use a module like Nodemon for but in a very focussed manner in ~20 lines of code.

Loader script

The loader script uses the experimental ES Module Loaders feature (soon, module customisation hooks).

Specifically, it:

  1. Resolves files with custom Kitten extensions (.page.js, .get.js, .post.js, .socket.js, .component.js, .lyaout.js, .fragment.js, .script.js, .styles.js, etc.) to JavaScript modules.

  2. Wraps non-Javascript fragments in .fragment.md and fragment.css files as well as non-JavaScript pages in .page.md files with JavaScript wrappers to render them as Kitten HTML.

šŸ’”Using compound extensions enables Kitten to apply specialised logic to different types of files while enabling authoring tools (editors, etc.) to work with them without requiring any additional tooling (e.g., a custom language server).

šŸ’” The loader loads before the main process so that the loader hooks are available and used for all imports in Kitten itself as well as the app/site Kitten is serving. The loader runs in a separate realm to the Kitten bundle. Currently, there is no inter-process communication between the two realms and, given the design of Kitten, it is not likely that there will need to be in the future either.

Kitten bundle

Kitten is always run from the Kitten bundle created using the build script and installed using the installer. This ensures that what we’re testing in development is exactly the same as what we’ll be running in production.

The entrypoint to the Kitten bundle is the main script.

Main script

The main script is the entrypoint to the Kitten bundle and handles basic bootstrapping tasks before launching the Command-Line Interface (CLI).

Specifically, it:

  1. Creates the global kitten object and populates it with the version property. (Other properties are added later during Kitten’s start-up process.)

  2. Monkeypatches console.debug so that output sent to it is only shown on the console if the VERBOSE=true environment variable is set e.g., if Kitten is started with VERBOSE=true kitten.

  3. Runs any migrations for Kitten’s internal databases, if necessary.

  4. Initialises Kitten’s global internal database.

  5. Instantiates the CLI.

Command-Line Interface (CLI)

The Kitten Command-Line Interface (CLI) parses the arguments passed to Kitten command, if any, and configures and runs different commands based on them.

It uses the lightweight sade module to carry out the parsing.

When Kitten is invoked without any arguments, the default behaviour is to run the default serve command on the current working directory.

šŸ’” To see the full list of available commands, use the kitten help command. You can also find the usage information in the Command-line Interface (CLI) section of the usage guide, and each command is also covered in the Kitten Commands section of the Technical Manual, below.

All commands display a common Kitten header.

Kitten Header

The Kitten header is displayed at the top of every CLI command.

In the development environment, and if the terminal supports it, a sixel image of the Kitten logo is displayed at the start of the header. Otherwise, a text fallback with the Kitten emoji is displayed (ā€œšŸ± Kittenā€).

Following this is the author credit, detailed version information, a [link to the funding page] for Small Technology Foundation, and a help section with links to the Kitten tutorials, reference, issues.

Coloured output is provided via the chalk module and terminal links are created using the terminal-link module.

Kitten Commands

This is a full list of the Kitten Commands, detailing what they do and how they do it.

Serve

The serve command is the default command. So kitten serve and kitten are equivalent.

It attempts to create and initialise a Kitten Server, applying any passed Kitten server options, like the domain and port to serve on, any domain aliases, etc.

Prior to launching the Kitten Server, the serve command calculates the base path for the app/site being served as the relative path between the working directory and the path to serve, if any, that was passed as a Kitten Server option and changes the current working directory to that base path.

Kitten Server

The Kitten Server contains the primary functionality of Kitten and is a singleton that is asynchronously constructed via its getInstanceAsync() method.

Alongside the html parser and the page route class, it is among the densest of the files in Kitten. (And it might make sense to refactor it further to break it up more in the future to improve maintainability.)

The Kitten server uses Polka.

Source code:

Construction/configuration flow

As the Kitten Server is a singleton, you get a reference to it via its static factory method. Furthermore, since the initialisation of the Kitten Server instance is asynchronous, so is this factory method.

const server = await Server.getInstanceAsync(options)

The factory method returns a promise that either resolves immediately with a reference to the existing Kitten Server instance or does so after constructing and asynchronously initialising one.

Construction (synchronous)

The Kitten Server constructor handles any synchronous tasks it can and returns a partially configured Kitten Server instance. To fully configure the Kitten Server instance, asynchronous tasks are run in the asynchronous initialisation method.

šŸ–ļø Attempting to call the constructor directly will result in an error. Await the getInstanceAsync(options) method instead to get a reference to the Kitten Server singleton.

Source code:

The constructor handles a number of tasks, key among them, it:

Domains and domain aliases

The Kitten Server determines the main domain and port, as well as any domain aliases, if any, to respond to based on convention and the values present in the --domain, --port, and --aliases command-line options.

Source code:

Convention:

  1. If a domain is not explicitly specified using the --domain command-line option, the default domain of localhost is used.

  2. If the domain is localhost, we also respond to the following aliases: place1.localhost, place2.localhost, place3.localhost, and place4.localhost.

    These localhost aliases are used when testing peer-to-peer Small Web apps locally. Note that when you are running servers at these aliases, each one must have its own unique port.

  3. If a port is not explicitly specified using the --port command-line option, the default port of 443 is used.

  4. If the --port option is passed without a value, or, if its value is set to 0 (zero), a random, available port is used.

  5. If the domain is not localhost and a list of one or more comma-separated domains (without spaces between them) is provided as the value of the --aliases option, the server also responds to these domains.

  6. If the shorthand domain alias www is passed in the --aliases list, it is rewritten to its full www.<main domain> value.

Middleware

The Kitten Server makes use of a middleware that act on incoming requests before any routes do.

šŸ’” Middleware often modifies the request and response objects that routes receive to inject functionality that is useful during authoring.

šŸ’” The list, below, is presented in order of initialisation. Order matters in middleware as those initialised earlier will intercept the request earlier.

Third-party middleware:

First-party middleware (Kitten-specific middleware):

Initialisation (asynchronous)

šŸ–ļø Attempting to call the initialise() method directorly will result in an error. Await the getInstanceAsync(options) method instead to get a reference to the Kitten Server singleton.

Following the initial synchronous construction of the server instance, the singleton factory method awaits the server’s [async initialise()] method.

This method finishes configuring the Kitten Server by handling any asynchronous tasks that need to be done.

Specifically, it:

Routes

The Routes class manages the asychronous initialisation of the static and dynamic routes at a given base path.

The default base path is that of the primary web app/site that Kitten is serving and is kept in the basePath environment variable.

Routes.getRoutes(basePath = process.env.basePath)

The bulk of the work in instantiating server routes is split between the Routes class and the Files class it uses.

šŸ” Files class

The Routes initialiser enumerates the collection of files by extension type returned by the Files class and does two things:

  1. If run in a development environment, listens for the file event that fires when a file within the collection has changed.

  2. Creates one of the following two route types based on the type of file:

Static Routes

All static routes are of type StaticRoute and expose a very simple GET handler that uses the connect-static-file module to serve the file.

šŸ’” .gz files are supported and handled properly.

Dynamic Routes

Dynamic routes fall into two three broad categories, all of with are lazily-loaded routes (their handlers are lazily loaded on first hit):

Page Routes

Page routes are identified by their .page.js and page.md extensions (the latter for Markdown pages) and result in the creation of PageRoute instances.

šŸ’” Alongside Kitten Server and the html parser, page routes are the most complex parts of Kitten as they abstract away a lot of functionality to make authoring easier.

Page routes work in tandem with Page Socket Routes and the Page class to implement Kitten’s Streaming HTML workflow.

šŸ” Page Socket Routes

There are two ways to author Kitten pages:

Each method has its strengths and weaknesses and is useful for different use cases.

You’ll notice that many of the introductory examples and tutorials use functions. This is because function-based authoring is both more familiar from other JavaScript frameworks and is easier to get started with. It’s perfect for quickly prototyping things and for quick experiments.

🚧 Class-based authoring, on the other hand, involves a bit more overhead but affords better maintainability. In conjunction with the KittenComponent class, it enables you to work in a fully object-oriented manner in composing your pages from a DOM-like hierarchy of components, complete with automatic event bubbling so you can encapsulate event handling and streaming HTML responses within components themselves.

Since you can move from function-based page routes to class-based ones easily, it’s always possible to start with the former and refactor to use the latter only if you find that you are going to be maintaining the app/site you’re building long term or if you start hitting the point where refactoring becomes a chore.

šŸ’” A good smell for when to refactor to use class-based pages is if you find yourself handling lots of client events in a central onConnect() handler and the handler starts to become unmanageable or unsightly.

Page types

There are three types of pages in Kitten:

  1. Dynamic pages
  2. Chached pages
  3. Live pages
Dynamic pages

The dynamic page is the default page type in Kitten.

All you have to do to create a dynamic page is to expose a default route that returns kitten.html.

For example, the following displays the current date and time every time the page is reloaded:

export default () => kitten.html`
  <h1>${new Date()}</h1>
`

Dynamic routes are re-rendered (have their default function run and the results sent to the browser in response to GET requests) whenever the route is hit.

That’s all there is to dynamic pages.

šŸ’” All HTTP routes – e.g., GET, POST, etc. – are also rendered on every hit. If you want to cache their results, you have to implement your own caching mechanism. (Which you could do using Kitten’s built-in database support.)

Cached pages

🚧 [ ] Implement.

Cached pages are dynamic pages that export a special flag that tells Kitten to only render the page the first time it is hit:

// Cache response until server restarts.
export const cache = true

This will make Kitten cache the page until the server restarts (which will happen, for example, if Kitten or the app is updated).

Optionally, you can also set the cache value to the time, in minutes, that you want content cached for:

// Cache response for a day.
export const cache = 24 /* hours */ * 60 /* minutes */
Live pages
Sequence diagram of the live page flow as described in text in the body of this page.
Sequence diagram of live page lifecylce.

Live pages are dynamic pages that automatically a create a socket connection to client and use this connection to enable a Streaming HTML workflow where you can respond to events raised on the client with interface updates generated on the server that you stream to the client.

To create a live page, you must export at least one event handler from your page file. Event handlers are written in camelCase and start with on, followed by the name of the event you’re handling.

By convention, the name of the event is set using the name attribute of the client side DOM element that the event originates from.

If the element that triggers an event does not have a name attribute, Kitten will fall back to using its id instead.

If neither the name nor id attributes exist on a triggering element, Kitten will display an error message and fail to route the message.

šŸ’” If both name and id attributes are present, name takes precedence. The name attribute is chosen as the recommended attribute for event mapping as more than one element can have the same name, which means that you can have events on multiple elements all result in the same event handler getting called. This is useful, for example, when there are multiple buttons that submit the same form but are meant to result in a different action being performed. Another use case is radio button groups.

Kitten handles the transport of the event from the client, via the automatically-created default web socket, to the Kitten page (and any connected Kitten components) on the server.

Kitten pages can have components connected to them. Connected components automatically receive events that are sent from the page. Connected components can, in turn, have other components connect to them. Events bubble up the component hierarchy automatically. You do not have to add event listeners to be notified of events. All you have to do is to implement an event handler, using the naming convention outlined above.

šŸ’” Live pages are more resource intensive than regular pages not only because each live page also creates a socket route but because the live page and any components on it are kept in memory for as long as the page is alive on the client. So, if you have 1,000 concurrent people on a live page, you’re going to have 1,000 live page instances. If that page has a component hierarchy where there are 10 components on a page, you’re going to have 10,000 live components in memory.

While this may seem excessive, for the type of functionality Kitten provides with live pages, you would end up having to keep as much in memory even if you implemented it yourself, although you might get away with optimisations using more efficient data structures. However, given Small Web use cases, this should not present a scalability problem for personal web sites and apps.

(If it does, you can always not use live pages and implement a more efficient system using your own custom data types and persistence mechanisms.)

šŸ’” You can create live pages using either function-based or class-based routes and using function-based or class-based component but only the class-based ones will take advantage of the automatic event bubbling, etc.

Page flow.
  1. A request comes in to the lazilyLoadedHandler() override.

  2. Just like other lazily loaded handlers, if the internal handler method (_handler) doesn’t exist yet, it is created.

  3. Properties shared by all the routes are saved to the route instance itself. (e.g., request.url)

  4. If the route is for Kitten’s own internal sign-in route (/šŸ’•/sign-in/), populates the redirectToAfterSignIn property on the session so that Kitten knows where it needs to redirect the person after they sign in.

  5. 🚧 If any event handlers are exported from the page route, that means that this is a live page, so create a Page instance in memory to track its lifecycle and enable the handling of events from the client as well the streaming of responses back to the client.

  6. Call custom event handler exported by the developer, passing it a reference to the Page instance as well as the HTTPS request and response objects.

  7. If the page has ended the response itself (this is not encouraged, but is possible), do nothing else.

  8. Do the final render of the page, wrapping it up in common Kitten functionality and a valid HTML shell.

  9. Update the hit counter for the page in (kitten._db.stats).

Page Socket Routes

Page Socket Routes are special in that, unlike all other types of routes, they do not have their own file on the file system.

🚧 Instead, they are automatically-created from Page Routes that either export page event handlers (on…()) or export the page as an instance of the kitten.Page class.

If a page is called, say, /my.page.js in the site/app being served, the path of its route will be /my/. If it exports event handlers or an instance of the kitten.Page class, a Page Socket Route will be automatically created for it at the path /my.default.socket.

Furthermore, the page that is rendered by the Page Route’s handler will automatically contain code to make a WebSocket connection to the Page Socket Route.

When the /my/ route is hit, it will;

  1. Lazily load the default export in /my.page.js.
  2. Create a Page instance for this request (this is known as a live page) and
  3. Among other things, inject code to connect to /my.default.socket and pass it the Page instance’s id on load.

The Page instance, while being instantiated:

  1. Generates a cryptographically secure universally unique identifier (UUID) to act as the page id.

  2. Adds it’s id to the global registry of live pages at kitten.pages.

  3. Emits a kittenPageCreate event.

Then, when the page loads in the browser and makes a connection to the automatically-generated default WebSocket, the Page Socket Route:

  1. Wires up the WebSocket to handle keep alive ping/pongs.

  2. Adds the connection to the list of connections for this socket.

  3. Checks that the Page instance’s id is provided in the query (request.query.id), handles the error if it isn’t by telling the page to reload itself, and otherwise sets the socket’s id (ws.id) to the page’s id.

  4. Call’s the Page instance’s connect method, passing it references to the WebSocket, its list of connections, and its event handlers that were imported from the page route.

  5. On socket close, sets up an event handler to perform clean up by removing the associated Page instance from the global registry of live pages.

The Page instance, when its connect method is called:

  1. Saves the socket, connections, and event handlers it is passed by the Page Socket Route.

  2. Creates two MessageSender properties, everyone and everyoneElse, that are used by author of the Page Route to broadcast messages to all connections or to all connections apart from the current connection, respectively.

  3. Broadcasts a generic kittenPageConnect event as well as a specific kittenPageConnect-<page id> event.

In addition to message sending functionality, the Page instance contains framework code that handles the parsing of incoming messages and the mapping of parsed messages to event handlers (onEventName()).

Post routes

Post routes respond to POST requests and are identified by their post.js extension. They result in the creation of PostRoute instances.

They have a bit of additional logic to other HTTP routes for handling file uploads and they inject an uploads property in the request objects received by the route handler to aid in authoring.

Socket routes

Socket routes simplify the use of WebSockets in Kitten apps/site. They are identified by their .socket.js extensions and result in the creation of WebSocketRoute instances.

Socket routes make use of the ws reference injected by the Kitten Server’s WebSocket middleware and implement keep-alive and connection handling as well as providing a high-level interface for authors to send and respond to WebSocket messages.

When using Socket routes authors receive a parameter object that contains a request object as well as a socket object.

The socket object is implemented internally as a Proxy that mixes in the following two methods to the underlying ws object:

- `broadcast()`
- `all()`

The broadcast() method send the passed message object (which can be an arbitrary object or a string) to everyone but the socket that is broadcasting.

To send to everyone, including the socket that originated the message, use the all() method, instead.

The extent of error handling is limited to logging an error message to the console. To customise error handling, you can add your own .on('error', …) handler to the returned socket proxy.

Other HTTP Routes

All other route types (e.g, .get.js, head.js, etc.) are handled by the generic/simple HTTPRoute class that simply passes the request and response objects to the route’s handler and expects the person authoring the route to handle the responses themselves without injecting any higher-level functionality as with the other route types.

Files

The Files class finds files to serve by recursively searching its base path and watches for changes on them, dispatching file events when changes occur.

It is used by Routes when creating the file system-based routes for Kitten Server.

The Files class is an EventTarget.

The Files class is instantiated using an asynchronous factory method (new).

const files = Files.new(basePath)

Files are categorised by extension category type:

console.log(files.byExtensionCategoryType)

Files uses the Watcher module for file system watching and the tiny-readdir module to get the list of files from the file system. (The latter module is also used by Watcher itself.)

While compiling the list of paths for routing, the following paths are ignored:

šŸ’” The same ignore list is used when deciding which files to watch, with the exception of app_modules folders, which are watched for changes, resulting in live reloads during development if you change a file in the app module you’re working on (just like with any other file in your Kitten app/site).

ā—Currently, the file map is created twice.

First, we use tiny-readdir (which is also used by Watcher), to create our initial list of files. Then we instantiate Watcher. We should be able to pass the returned readdirMap to Watcher so it doesn’t have to replicate the directory traversal but there is a bug in Watcher that’s currently preventing this.

Keep an eye on the following two Watcher issues:

Files dispatches a file event with a detail property of type FileEvenDetails for the following events:

On Watcher error, it closes the watcher.

Extension category types

Files are sorted into the following category types, based on their extensions:

šŸ’” The allRoutes list contains all the routes from the other categories, combined, and is included for ease of authoring.

Questions?

Contact Aral on the fediverse.

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.