HTMX with Bun: A Real World App

sjdonado

sjdonado

Posted on March 5, 2024

HTMX with Bun: A Real World App

Let's build a real-world app with HTMX + Tailwind CSS + Bun. If you are already familiar with these tools, feel free to skip the context part.

Context

HTMX, HTMZ, HTMY...

All we need to achieve decent smooth reactivity is already out there, built-in by the major browsers. As an example, here is one interesting front-end framework:

<iframe hidden name=htmz onload="setTimeout(()=>document.querySelector(contentWindow.location.hash||null)?.replaceWith(...contentDocument.body.childNodes))"></iframe>
Enter fullscreen mode Exit fullscreen mode

That's it, just an iframe, try it out yourself on https://leanrada.com/htmz/.

The philosophy behind this is the same as HTMX and the same one that drives this post: Don't reinvent rendering, just use HTML.

HTMX meme

The blazingly fast JS server

With HTMX in mind, the next step is to get up and running a a simple web server.

Bun has gained a lot of popularity in the last few months. It is fast, and there is a framework called ElysiaJS that resembles Express.js and promises to be 22 times faster than Express.

ElysiaJS Benchmark

Additionally, Bun understands TypeScript and JSX out of the box, so we can structure our app with components, server-side render them, and rely on HTMX for client-side actions.

Tailwind CSS
Unlike traditional CSS frameworks that come with predefined components, Tailwind CSS emphasizes atomic classes that represent individual CSS properties. The bundle size could be lighter in comparison to other tools, but the strength here, IMHO, is rapid prototyping.

A Real World App

I Don't Have Spotify Web App

The problem: I'm an Apple Music user. Someone shares a Spotify link with me, and I don't have a way to know what's inside or listen to it.

The solution: I Don't Have Spotify scrapes the links (songs/albums/artists etc) from all your favorite streaming services, and it even provides a listening preview if you just want a quick look.

The approach

  1. Given a Spotify link, we need to extract its metadata: Fetch request + HTML parsing.
  2. Given the metadata, we need to search for the resource on each supported streaming service: Multiple API/HTML requests + parsing (adapters).
  3. Given the results of (1) and (2), we need to render and return the data to the client.
  4. Additionally, we list a JSON endpoint for a cool Raycast extension.

There are two major design decisions:

  • The folder structure / separation of concerns
  • Tooling + build process, how to live reload the JSX components?

For the first one, since we have multiple streaming services, we'll inevitably encounter shared logic between them. Drawing inspiration from the adapter pattern but without the boilerplate, I organized the adapters folder with pure functions. These functions all receive the same arguments: query: string and metadata: SpotifyMetadata, and they return a SpotifyContentLink.

The adapters are called by the spotifySearch function in the search service, which is also responsible for caching and updating the statistics. Why caching? In short: these calls are quite time-consuming, and we can easily hit a rate limit if we send many of them at the same time.

For the second aspect, running Bun is very straightforward: bun run --watch www/bin.ts. However, as a real-world app, we need some JavaScript on the client side, apart from sending AJAX requests. There is an audio player that has to be rendered on the client side (in order to append listeners to the DOM). Additionally, we would like to access the Clipboard API to improve the user experience when searching.

To bundle and minify the required JavaScript, I used Vite with the rollup-plugin-copy plugin:

import path from 'path';

import { defineConfig } from 'vite';
import copy from 'rollup-plugin-copy';

export default defineConfig({
  plugins: [
    copy({
      targets: [
        {
          src: 'dist/*',
          dest: 'public',
          copyOnce: false,
        },
      ],
      hook: 'writeBundle',
    }),
  ],
  resolve: {
    alias: {
      '~/config/constants': path.resolve(__dirname, './src/config/constants.ts'),
    },
  },
  build: {
    outDir: './dist',
    target: 'esnext',
    rollupOptions: {
      input: {
        'assets/js/audio-preview': './src/views/js/audio-preview.js',
        'assets/js/search-bar': './src/views/js/search-bar.js',
        'assets/css/index': './src/views/css/index.css',
      },
      output: {
        entryFileNames: '[name].min.js',
        chunkFileNames: `[name].min.js`,
        assetFileNames: `[name].min.[ext]`,
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Showcase

  • The search bar component (where HTMX shines)
export default function SearchBar() {
  return (
    <>
      <form
        id="search-form"
        hx-post="/search"
        hx-target="#search-results"
        hx-swap="innerHTML"
        hx-indicator="#loading-indicator"
        hx-request='\"timeout\":24000'
        class="flex w-full max-w-3xl items-center justify-center"
      >
        <label for="song-link" class="sr-only">
          Search
        </label>
        <input
          type="text"
          id="song-link"
          name="spotifyLink"
          class="flex-1 rounded-lg border bg-white p-2.5 text-sm font-normal text-black placeholder:text-gray-400 lg:text-base"
          placeholder="https://open.spotify.com/track/7A8MwSsu9efJXP6xvZfRN3?si=d4f1e2eb324c43df"
          pattern={SPOTIFY_LINK_REGEX.source}
        />
        <button
          type="submit"
          class="ml-2 rounded-lg border border-green-500 bg-green-500 p-2.5 text-sm font-medium text-white focus:outline-none focus:ring-1 focus:ring-white"
        >
          <i class="fas fa-search p-1 text-black" />
          <span class="sr-only">Search</span>
        </button>
      </form>
      <div class="my-4">
        <div id="search-results"></div>
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • The web server
import { Elysia } from 'elysia';
import { html } from '@elysiajs/html';
import { staticPlugin } from '@elysiajs/static';

import { logger } from './utils/logger';

import { apiRouter } from './routes/api';
import { pageRouter } from './routes/page';

export const app = new Elysia()
  .use(html())
  .use(
    staticPlugin({
      prefix: '',
      headers: {
        'Cache-Control': 'public, max-age=86400',
      },
    })
  )
  .on('beforeHandle', async ({ request }) => {
    logger.info(
      `${request.method} ${request.url} - ${request.headers.get('user-agent')}`
    );
  })
  .use(apiRouter)
  .use(pageRouter);
Enter fullscreen mode Exit fullscreen mode

Testing

In our case, testing involves mocks and more mocks. They run on push changes to master, thanks to GitHub Actions:

Testing output from Github Actions

The setup relies on bun:test and AxiosMockAdapter. It is separated into integration and unit tests, and the requests are injected using two helpers:

export const JSONRequest = (endpoint: string, body: object) => {
  return new Request(endpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });
};

export const formDataRequest = (endpoint: string, body: object) => {
  const formData = new FormData();

  Object.entries(body).forEach(([key, value]) => {
    formData.append(key, value);
  });

  return new Request(endpoint, {
    method: 'POST',
    body: formData,
  });
};
Enter fullscreen mode Exit fullscreen mode

Incorporating e2e shouldn't be complex. An example of running the dev server with Playwright:

...
  webServer: {
    command: 'bun run dev',
    port: 3333,
    reuseExistingServer: !process.env.CI,
  }
Enter fullscreen mode Exit fullscreen mode

Wrapping up

💖 💪 🙅 🚩
sjdonado
sjdonado

Posted on March 5, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related