Menu
Tags

Elmtx Part 1 - An experiment withe Elysia & HTMX

Published on Mar 25, 2024

Table of Contents

The good stuff:

The Goal - What this hype all about?

The internet seems to love HTMX. I want to find out why. There is something appealing about stipping back the fluff and building an old school server that servers up HTML, so lets build it.

I want to build a modern application, with user auth and a simple CRUD item. I also want to design this in a way that makes for a super effective, super simple boilerplate for building more complex applications.

Of course, we’ll write some tests and get this demo deployed so we can see it live.

The Stack

  • elysiajs: The server. It’s bun. It’s fast. Fast is good.
  • prisma: ORM & SQLite DB. Will explore using Turso with this project as well.
  • htmx: Handle all the requests and swapping of HTML.
  • picoss: My goto for basic styling.
  • alpinejs: For fancy UI stuff.
  • jsx: templating engine of sorts. I’m not much of a react guy, so this will be a learning curve.

The basic structure

Elysia defines the server, and returns HTML for all responses. We create templates using JSX. Let’s look at the main index JSX:

index.tsx
import { Nav } from "./components/Nav";

export const index = (user) => {
    return (
        <html lang="en">
            <head>
                <title>Elmtx</title>
                <meta charset="UTF-8" />

                <link
                    rel="stylesheet"
                    href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
                />

                <script src="https://unpkg.com/htmx.org/dist/htmx.js" />
                <script src="https://unpkg.com/alpinejs" defer />
            </head>
            <body class="container">
                {user ? <Nav user={user} /> : <Nav />}
                {user ? (
                    <main id="content" hx-get="/account" hx-trigger="load" />
                ) : (
                    <main id="content" hx-get="/login" hx-trigger="load" />
                )}
            </body>
        </html>
    );
};

This index is ends up creating a psuedo single page application. We check if a user has been passed in, and adjust the contents of the main element based on that. We use HTMX to call either the /account route for logged in users, or the /login route for un-registered users. In that way, we are only ever changing the content through HTMX requests moving forward. Long story short, we use hx-get on the ‘load’ trigger to determine what to render in the main element.

User auth

User auth was the biggest time sink in building this. The documentation for Elysia specifically is a bit lacking, so it took lots of experimentation, but in the end, we’ve got OAuth working pretty smoothly using Lucia Auth. The code if pretty verbose with all of the token generation and checking and type validations, so I won’t gum up this post with the whole thing. But the key takeaways:

  1. We move all auth logic to a different file, use the Elysia prefix config and import it within our index. It a very handy feature of Elysia:
auth.tsx
export const auth = new Elysia({ prefix: "/auth" })
index.tsx
const app = new Elysia().use(html()).use(auth)
  1. We create some endpoints for each OAuth provider. First, we have an endpoint to generate the tokens and create the login URL using the arctic package:
auth.tsx
.get(
        "/google",
        async ({ set, cookie: { google_state, google_code_verify } }) => {
            google_state.remove();
            google_code_verify.remove();
            // We create some tokens using arctic
            const state = generateState();
            const codeVerifier = generateCodeVerifier();
            // We generate the URL to redirect the user to Google Login
            const url = await google.createAuthorizationURL(
                state,
                codeVerifier,
                {
                    scopes: ["profile", "email"],
                }
            );
            // We store the state and codeVerifier in cookies
            google_state.value = state;
            google_code_verify.value = codeVerifier;
            google_state.set = {
                httpOnly: true,
                secure: process.env.NODE_ENV === "production",
                maxAge: 60 * 60 * 24 * 7,
                path: "/",
            };
            google_code_verify.set = {
                httpOnly: true,
                secure: process.env.NODE_ENV === "production",
                maxAge: 60 * 60 * 24 * 7,
                path: "/",
            };
            // We redirect the user to the URL
            return (set.redirect = url.toString() || "");
        }
    )
  1. Then, we have the callback URL that we defined when setting up the OAuth tokens:
auth.tsx
.get(
        "/google/callback",
        async ({
            set,
            query,
            cookie: { google_state, google_code_verify, lucia_session },
        }) => {
            console.log("at callback");
            // We get the state and code from the query params
            const { state, code } = query;
            // We get the state and codeVerifier from the cookies
            const stateCookie = google_state.value;
            const codeVerifier = google_code_verify.value;

            console.log(state, code, stateCookie, codeVerifier);

            // We validate we have all the necessary data, and the state and codes match.
            if (
                !state ||
                !stateCookie ||
                !code ||
                stateCookie != state ||
                !codeVerifier
            ) {
                return (set.status = 400);
            }

            console.log("at callback 2");
            try {
                const tokens = await google.validateAuthorizationCode(
                    code,
                    codeVerifier
                );
                const response = await fetch(
                    "https://openidconnect.googleapis.com/v1/userinfo",
                    {
                        headers: {
                            Authorization: `Bearer ${tokens.accessToken}`,
                        },
                    }
                );
                const user = await response.json();
                console.log(user);
                // See if user is in the database:
                const dbUser = await prisma.user.findFirst({
                    where: {
                        provider_id: user.sub,
                    },
                });
                // Create a session if user exists
                if (dbUser) {
                    const session = await lucia.createSession(dbUser.id, {});
                    const sessionCookieLucia = lucia.createSessionCookie(
                        session.id
                    );
                    console.log(sessionCookieLucia);
                    lucia_session.value = sessionCookieLucia.value;
                    lucia_session.set(sessionCookieLucia.attributes);
                    return (set.redirect = "/");
                }
                // Add user if there is no user in the database:
                if (!dbUser) {
                    console.log("adding user");
                    const id = generateId(12);
                    await prisma.user.create({
                        data: {
                            id: id,
                            provider_id: user.sub,
                            provider_user_id: user.name,
                        },
                    });
                    // Create a session after adding user
                    const session = await lucia.createSession(id, {});
                    const sessionCookieLucia = lucia.createSessionCookie(
                        session.id
                    );
                    lucia_session.value = sessionCookieLucia.value;
                    lucia_session.set(sessionCookieLucia.attributes);
                    return (set.redirect = "/");
                }
            } catch (error) {
                console.log(error);
                return (set.status = 400);
            }
        }
    )

Huge shoutout to Andrey Fadeev’s youtube video, because it was a massive help in getting this going.

CRUD

The account page is simple. A form for creating new ‘cards’, and a place to display those cards.

Account.tsx
export const Account = () => {
    return (
        <div>
            <article>
                <form hx-post="/add-card" hx-swap="none" id="add-form">
                    <label for="title">
                        Title
                        <input type="text" name="title" />
                    </label>

                    <label for="description">
                        Description
                        <input type="textfield" name="description" />
                    </label>
                    <label for="archive">
                        Archive?  
                        <input type="checkbox" name="archived" />
                    </label>
                    <button id="new-submit" type="submit">Submit</button>
                </form>
            </article>
            <article>
                <h1>Cards</h1>
                <div hx-get="/cards" hx-target="#cards" hx-trigger="load, cardEdit from:body">
                    <div id="cards">
                        <p>Loading...</p>
                    </div>
                </div>
            </article>
            <article>
                <h1>Archived Cards</h1>
                <div hx-get="/archived-cards" hx-target="#archived-cards" hx-trigger="load, cardEdit from:body">
                    <div id="archived-cards">
                        <p>Loading...</p>
                    </div>
                </div>
            </article>
        </div>
    );
};

Here, we are really starting to flex our HTMX. We have the form that will use the typical ‘hx-post’, but underneath we have two different article elements to house the cards. Here, we want to load the cards both on load, and when we edit a card. This took some figuring out, but the HTMX examples page delivered the solution. We simply add an ‘HX-Trigger’ attribute to the requests headers, and add that trigger flag to the hx-trigger attribute. Let’s look at the delete end point for example:

index.tsx
app.delete('/delete-card/:id', async ({params, set, cookie: {lucia_session}}) => {
  // Lets set the header straight away.
  set.headers = {
    'HX-Trigger': 'cardEdit'
  }
  // Collect the ID from the URL params.
  const id = params.id;
  // Make sure there is a session and a valid user.
  if(lucia_session.value){
    const { user } = await lucia.validateSession(lucia_session.value);
    if(user){
      // Delete the card
      const card = await prisma.card.delete({
        where: {
          id
        }
      })
    }
    // Nothing to return
    return null;
  }
  // Redirect to login if there is no session or user.
  return (set.redirect = '/login');
})

Finally, we have a card component, and a card-list component to render each ‘card’. The card-list component isn’t terribly exciting…except we had to use some inline styles for the time being.

import { Card } from "./Card";

export const CardsList = (cards) => {
    return (
        <div style="display:flex;flex-wrap:wrap;flex-direction:row;gap:1rem;">
            {cards.map((card) => {
                return Card(card);
            })}
        </div>
    );
};

The more interesting component is the card itself, and how we implement editing:

components/Card.tsx
export const Card = (card) => {
    return (
        <article style="width: 45%">
            <header>
                <h2>{card.title}</h2>
            </header>
            <body>
                <p>{card.content}</p>
                <label for="archived">
                    Archived?:
                <input name="archived" type="checkbox" checked={card.archived} hx-patch={`/toggle-archived/${card.id}`} />
                </label>
                <button id="delete" hx-delete={`/delete-card/${card.id}`}>Delete</button>
            </body>
        </article>
    );
};

Here, we use both ‘hx-delete’ and ‘hx-patch’ to delete and update the card respectively.

The overall structure is pretty simple. Elysia endpoints define what to return to particular endpoints. Our JSX templates render the output.

Next Up - A real app, testing and deployment

One of my fears of working with Bun is figuring out deployment, which will be the next part in this series, along side some robust Playwright and Vitest testing.