Bun Blog

A simple blog website I made to try out Bun JS runtime

Bun Blog

Published: Fri, 09 Feb 2024 01:19:54

I wasn't entirely happy with my Emacs CMS blog project. It felt like a bit of a disorganized prototype. I had HTML lurking around in my index.js along with all of the logic for the whole app, it could only handle uploading .png files. Using a web framework like Express also felt really excessive for such a simple application. I had a few people suggest that the Bun JavaScript runtime as an alternative to Node that was worth checking out so I decided to take another stab at the project using that, cheerio and mustache. The entire project is located here on my GitHub.

Bun vs. Node

Bun is a really interesting alternative to Node. It comes with a really nice Bun.serve() method for receiving and sending HTTP requests and responses so its very easy to just make a nice REST api right out of the box. It also comes with the ability to read .env files into process.env, which is a nice convenience. It also has Bun.file(), which is a replacement for Nodes fs.readFile(), that can read relative paths without the need to join a relative path with __dirname. A lot of these things come down to convenience replacements for common tasks, but the Bun website makes some claims about being super fast. Without doing a lot more research, I will take that with a grain of salt for now.

Getting the data from Emacs

Similar to my other project I use the following function to get my data from Emacs and my local machine to the remote server.

     (defun nao/export-org-to-blog ()
      (interactive)
       (setq org-html-doctype "html5")
      (let ((filename (org-html-export-to-html nil nil nil t)))
        (copy-file (concat (file-name-sans-extension filename) ".png")
                   "/rsync:server:/path/to/images" t)
        (copy-file filename "/rsync:server/path/to/html" t)))

Org mode also comes with a series of publish functions that, I am assuming, accomplish a similar task. This above function did almost exactly what I needed, so I haven't looked into the org publish functionality, but perhaps I will look into switching to that in the future if the website becomes more complicated.

Bun REST api

On the server I use a very simple REST api to direct traffic to resources by making use of Bun.serve():

  try {
    if (/\.(css|png|svg)$/.test(req.url)) return await getStatic(req.url);
    if (url.pathname === "/") return await getHome();
    if (url.pathname === "/blog") return getBlogs();
    if (url.pathname === "/cards") return getCards();
    if (/\/blog\/.*\.html/.test(req.url)) {
      const match = req.url.match(/\/blog\/(.*\.html)/);
      return getPost(
        match![1],
        req.headers.get("hx-request") === "true"
      );
    } 
    return new Response("404!");
  } catch (e) {
    console.error('Error:', e);
    return new Response("500 - Internal Server Error");
  }

I took the basic structure from the Bun documentation and I use Regex tests to match routes. The first route matches routes with css or image filenames to server those images as they are requested and the other routes direct to modules where I use Mustache templates to create the response for the client. req.headers.get("hx-request") === "true" being based to getPost() tests to see if a request has the hx-request header. I do this because I use HTMX to avoid page reloads, but I need a way to determine if a resource was requested from outside the page. If the header is not present, I know the user navigated from an external link so it knows whether or not it needs to serve the index.html.

  if (hx) {
    return new Response(post);
  } else {
    const view = {
      API_URL: API_URL,
      post: post,
    }

    const html = Mustache.render(index, view);

    return new Response(html, {
      headers: {
        "Content-Type": "text/html",
      },
    });
  }

inside getPost() if hx-request is true, I send just the HTML for the post, if its false, then I use Mustache to add the post to the index.html and send it as one HTML file.

Serving Static Files

The module that routes to the static file resources users regex similar to the main REST api:

  if (/\w+\.css$/.test(filename)) {
    try {
      const file = Bun.file(`static/css/${filename}`);
      return new Response(file);
    } catch (e) {
      console.error(e);
      return new Response("404: file not found");
    }
  }

String.match() extracts the filename from the url and then I use regex to map the file extensions to the appropriate directories and then return the requested file. The most likely reason for an error is that the file does not exist, but regardless of the error, I log the error on the server and respond with 404.

Generating thumbnails

Initially to create the images for the cards I was simply taking screenshots and then uploading the results as is. This meant that the files were much larger than they needed to be and slow to load. Furthermore, they were inconsistent in terms of size and aspect ratio making the cards inconsistently sized. In order to solve this problem, I installed sharp in order to transform the images, but I wanted a way to automate it.

I created a simple module to achieve this. First I took the example code from the Bun documentation to watch my images directory for changes. When I use rsync to upload a new file the watch will trigger and provide me with the filename that was changed. I also add some logic to make sure that the file has appropriate extensions, in this case I check for png and jpg, and isn't a thumbnail itself.

Next I check to see if I have access to the file using fs.access(). If an image is moved from the directory, then sharp might try to transform an image that no longer exists, causing an error.

async function checkAccess(filename: string) {
  try {
    await access(`${imagesPath}/${filename}`, constants.R_OK | constants.W_OK);
    resizeImage(filename);
  } catch {
    return;
  }
}

Finally after everything has been appropriately validated, I check to see if there is already a thumbnail generated for that image, and if there is, I delete it so that, if the image has changed, the thumbnail will be updated appropriately. In theory this might cause an issue where, if the thumbnail is requested at the exact moment that I am remaking it, it would not be available. Perhaps a solution to this would be to create a separate staging directory and sync the images after they are updated. I may do this in the future if I create module for updating the post HTML to semantic HTML5.

I then run sharp().resize() with first two arguments being the width and height of the new image in pixels. In the options argument, I set fit to "cover", which has a similar effect to a CSS overflow hidden, but it will actually crop the image. This might cause it to be cropped in an undesirable way, but I think the best solution here is to crop the image properly before I upload it.

async function resizeImage(filename: string) {
  const images = await fs.readdir(imagesPath);
  if (images.includes(`thumb-${filename}`)) {
    try {
      await fs.rm(`${imagesPath}/thumb-${filename}`);
      console.log(`Deleted old thumb for ${filename}`);
    } catch (e) {
      console.error("Error deleting old thumb: " + e);
    }
  }

  try {
    console.log(`Processing image ${filename}`);
    sharp(`${imagesPath}/${filename}`)
      .resize(384, 185, {
        fit: "cover",
      })
      .toFile(`${imagesPath}/thumb-${filename}`);
  } catch (e) {
    console.error("File processing failed" + e);
    return;
  }
  console.log(`${filename} resized successfully`);
}

fs.watch vs. chokidar

Another possible solution would be to use chokidar. Chokidar still relies on fs.watch() and fs.watchFile(), but aims to resolve an issue where, depending on OS, the eventType returned by fs.watch() will always be "Rename" regardless of what is actually happening to the file. Since Chokidar still relies on the same fs methods as I am using, it is probably using similar techniques to return more appropriate events as I am using to validate that the file exists, which would indicate a move or delete event, and will add a dependency that is probably not really needed for the sake of saving a few lines of code. If I later find myself doing more extensive file system work that relies on fs.watch() I might reach for chokidar to avoid having write similar methods myself.

System Crafters Web Ring

Messing around with computers and coding since I was 8. Now getting paid to do what I love.