Hi, I’m Erika Rowland (a.k.a. erikareads). Hi, I’m Erika. I’m an Ops-shaped Software Engineer, Toolmaker, and Resilience Engineering fan. I like Elixir and Gleam, Reading, and Design. She/Her. Constellation Webring Published on

I created a little webring in Gleam

Recently, my friends have been building cool web 1.0 features for their websites, such as this hit counter. Inspired by web 1.0, I decided to create a web ring for me and my friends.

What is a webring?

A webring is a way of adding discoverability for sites in the ring.

On a technical level, it’s a doubly linked list of websitesDiagram made with mermaid.live:

webring example with three websites all pointing to each other

Each website links to the next and previous website in the linked list, creating a “ring” of websites that are all connected.

In theory, it’s possible to literally link to the next site in the ring, but this quickly becomes impractical as member sites join or leave the ring. The solution is to write a little webring service:

How do you run a webring service?

Instead of having member sites link directly to each other, a webring service can handle routing each “next” and “previous” link to the appropriate place.

At its core, this service consists of three things:

  1. A way to identify where someone accessing the service is in the ring.
  2. A /previous endpoint to route people to the previous ring member.
  3. A /next endpoint to route people to the next ring member.

The endpoints can be powered by a simple HTTP 303 “See Other” redirectRead more about that here on the MDN documentation., so the hard part is figuring out where you are in the ring and what next and previous member sites are.

Where are you ringing from?

I’ve seen a few different ways to keep track of which member site the webring is being accessed fromAnother thing I considered was a from=<website> query parameter, but then I would have to worry about appropriately escaping web domains.:

  • An integer member number
  • A short hash id
  • EmojisHere’s a blog post from the person who runs the Indieweb Ring about why Emoji IDs are problematic in practice.

Of these, I decided to use short hash ids, unique to each member site. I chose to use a SHA256 hash of the members URL, converted to padded, URL-safe base64 with a known saltTherefore, each member site only has to keep track of its own hash_id, and it’s designed not to change as members join or leave the ring.:

fn hash(string) {
  {string <> "salty salt"}
  |> bit_array.from_string()
  |> glesha.hash(glesha.Sha256)
  |> bit_array.base64_url_encode(True)
  |> string.slice(0, 16)

I’ll come back to how I used these hashes in a moment. First, we need to know who our neighbors in the ring are!

Ringing my neighbors

Starting from a list of member URLs, how do I turn them into a ring?It turns out that neither of the popular webrings that first come to my mind solve this problem.An Indieweb Webring just picks a random link every time as detailed at the bottom of this blog post.Devine’s webring just links to the index. This is nice because it means you only need to have one symbol/link on your website instead of previous/next links.

One thing I considered was using ZippersThanks to my friend Hayleigh for suggesting I think about Zippers., a functional programming technique for traversing data structures such as lists and trees.Sourceror, an AST manipulation library for Elixir uses Zippers, and has an excellent guide to them.

However, I don’t care so much about traversing the list quickly, so much as finding the immediate neighbors of each member. This reminded me of the list.window function in the Gleam standard library.

In particular, this example looks close to what I need:

window([1,2,3,4,5], 3)
// -> [[1, 2, 3], [2, 3, 4], [3, 4, 5]]

This function is missing the neighbors for 1 and 5, but otherwise is doing what I need. Looking at the implementation gives us this:

fn do_window(
  acc: List(List(a)), 
  l: List(a), 
  n: Int
) -> List(List(a)) {
  let window = take(l, n)

  case length(window) == n {
    True -> 
      do_window([window, ..acc], drop(l, 1), n)
    False -> acc

This function is asking: “Starting at the current element, what are the next n-1 elements after it?” Returning the list if there aren’t enough elements. But I tack on the first few elements when length(window) < n I’ll get what I need. With some optimizations to avoid length (which is an O(n) check in Gleam’s Erlang target), I get:

fn do_window(acc, l, original) {
  let window = list.take(l, 3)

  case window {
    [a, b, c, ..] ->
            previous: a, 
            next: c
        list.drop(l, 1), 
    ... // omitted
    [] -> acc

Since I’m already traversing the list, I go ahead and save the hash of each member while I’m there.

Now that I’ve computed all the neighbors, I can cache them into a Sqlite database. Member links are going to be read far more often than there will be changes in membership, so this caching is useful. It also allows us to index the field with the hash, giving us a faster than O(n) read time for links.


All together I’ve separated this caching logic into its own project: Constellation. This is just a webring for me and my friends, so I’m okay with manually running Constellation when I need to change membership.

Constellation reads from a members.txt that contains one member URL per line, computes the hashes and neighbors, and then creates a members.db which I can rsync to my server for use with the web service.

Now how does that web service work?

A Gleaming Web(ring) Service

Gleam’s web framework is Wisp which proved quite capable for my little webring.

There are a number of examples in the Wisp repo. My webring is only a little more than a fancy router, so I started from the routing example.Literally, you can see in my second commit that I forgot to change the readme.

Gleam doesn’t have any macros, which makes routing exactly pattern matching. Or as the comment in the example puts it:

Wisp doesn’t have a special router abstraction, instead we recommend using regular old pattern matching. This is faster than a router, is type safe, and means you don’t have to learn or be limited by a special DSL.

That gives my webring a router that looks like thisSince routing is just pattern matching, even complex web applications in Gleam use nested case statements or functions that contain fine grained pattern matching.:

pub fn handle_request(req: Request) -> Response {
  use req <- web.middleware(req)

  case wisp.path_segments(req) {
    [hash, "previous"] -> handle_previous(hash)
    [hash, "next"] -> handle_next(hash)

    _ -> wisp.not_found()

handle_previous looks like this:

fn handle_previous(hash) {
  let db_path = "members.db"
  use conn <- sqlight.with_connection(db_path)

  let previous =
      "select previous from ring where hash = ?",
      on: conn,
      with: [sqlight.text(hash)],
      expecting: dynamic.element(0, dynamic.string),

  case previous {
    Ok([previous_link]) -> 
    _ -> wisp.not_found()

There’s a lot going on in this function.

We have a use statement, which allows the function to close the database connection when it’s done.Here’s the language tour section on use sugar for more explanation.

Sqlite doesn’t know anything about Gleam types, so I use sqlight.text to let Sqlite know what type I’m passing in.

Gleam is statically typed, but we don’t know what type we’re going to get from the databaseEspecially with Sqlite, the authors of The Advantages of Flexible Typing.. The dynamic module from the standard library allows us to write a simple parser to convert what the database returns into types that Gleam understands.I initially wrote more boilerplate than I needed, but I realized that I could simplify the parsers.

Wisp has a built in function wisp.redirect that handles the HTTP 303 redirects that I need.

Up And Running

That’s it, after a similar handle_next function, I put up the service behind Caddy on a small server. If everything is working correctly, then it should be linked at the bottom of this article.For Desktop users, it should also be under my intro blurb at the top-left of the article.

The source code for the web service is here. Special thanks to Joe and Sara for inspiring me to write a webring. Here’s to making the internet a little cozier.

Extra Goodies

At this point in the explanation, the webring does everything it needs in order to be a ring, but we added a few extra features to make it shine:

Random Endpoint

Shortly after I got the webring up and running, I got a feature (and pull!) request to add a “random” feature.

Adding it to the router was simple:

pub fn handle_request(req: Request) -> Response {
  use req <- web.middleware(req)

  case wisp.path_segments(req) {
    [] -> home_page(req)

    ["random"] -> handle_random() // One line!

    [hash, "previous"] -> handle_previous(hash)
    [hash, "next"] -> handle_next(hash)

    _ -> wisp.not_found()

And the handle_random function itself takes advantage of Sqlite’s ORDER BY RANDOM():

select next from ring order by random() limit 1

Otherwise the redirection works exactly the same as handle_previous.

A Lustrous Home Page

I wanted to have something for people to see when they navigated to https://webring.club, the root domain of my webring.

Getting the members was a simple select member from ring SQL query:

let db_path = "members.db"
use conn <- sqlight.with_connection(db_path)
let members =
    "select member from ring order by member asc",
    on: conn,
    with: [],
    expecting: dynamic.element(0, dynamic.string),

I chose to use Lustre for html templating.I wrote about Lustre for interactive frontend before in Embedding Gleam in my blog with Vite. Lustre also offers backend templating. Lustre’s elements are just functions, so I can use them in a list.map over the members:

let members =
  result.unwrap(members, [])
  |> list.map(fn(member) {

let html =
  html.body([], [
      [html.text("The Constellation Webring")]
    html.ul([], members),
  |> element.to_document_string_builder()

Constellation Webring