fj laboratories

Musings, deliberations, and end results.

FJLaboratories — A Website Design Tour

Felix Jen – 07 July 2022 – 25 min read


Introduction

In this blog post, I’m going to deviate a little bit from my irregularly scheduled PCB and manufacturing content to talk a little bit about how the FJLaboratories website is architected and the various pipelines that it uses to deliver the robust image and search functionality that it has. I aim to talk about the basics overview of this website and some of the code being used to deliver the experiences on this site—mostly in private repos for security.

I’m going to end up talking about a lot of Cloudflare services that I use to power this site; however, it’s not an ad in any way. I am actually running all these services because I think it’s the best fit for the requirements of the site.

The Fundamentals

To truly understand the details of the image and search pipelines that are used on this site, we should understand the foundation that this site is built on. This site is, at its core, a Jekyll static site hosted on Cloudflare Pages. All pages are created as static HTML files with raw CSS and JS delivered directly from CF Pages. This means that the site is completely static and does not require any server-side code to be executed. This design was very purposeful since there is little-to-no content on the site actually changes over time. Instead, most of this content will remain static and consistent once it is created. Therefore, a static site was chosen both for speed of development and for speed of delivery.

Laptop on a desk

Cloudflare Pages was chosen as the hosting provider for a few big reasons: (1) Cloudflare Pages runs entirely free of charge for the most part, (2) Pages is automatically globally distributed across all Cloudflare Edge locations instantly, (3) Pages integrates closely with Workers and the Cloudflare CDN.

Aren’t Many Free?

Cloudflare Pages, compared to other competitors, such as Netlify, Vercel and GitHub Pages, is more free. Unlike other very-similar static site CI/CD providers, Cloudflare Pages exclusively charges for number of concurrent builds and number of total monthly builds. This is compared to services like Netlify which consider site bandwidth or Vercel which considered build-minutes rather than build-counts. As Jekyll is a fairly slow build platform, the build-minute billing is fairly disadvantageous compared to a build-count model.

The following chart sums up some of my primary concerns when it comes to free plans:

Feature or Property Cloudflare Pages Netlify Vercel GitHub Pages
Free Plan Available Yes Yes Yes Yes
Bandwidth Unlimited 100GB 100GB Unlimited
Build-Count 500/mth Unlimited Unlimited Unlimited
Build-Minutes N/A 300min/mth 6000min/mth Unlimited
Concurrent Builds 1 1 1 1
Build-per-Day Limits N/A N/A 100 N/A
Serverless Integ. Workers Functions Serverless Not Supported

One might notice on that chart that Github Pages also seems like a good option. However, due to some of the eccentricities of this site I’ll discuss in subsequent sections, Github Pages ultimately is not a great fit for the content.

Additionally, should I eventually move to a Paid model [spoiler alert: this site is running on the paid subscription], Pages have even less restrictions, upping the build-per-month to 5000 builds and the concurrent build limit to 5.

Global Distribution

Cloudflare Pages is the only hosting platform amongst the static site providers that automatically globally distributes across the Cloudflare network. Currently, Cloudflare runs the fastest network across North America, South America, and much of Europe. Since the primary visitors of this site located in these three regions, it makes a lot of sense to deploy directly on the Cloudflare network itself. As well, Pages runs on Cloudflare’s edge nodes. These nodes are extremely close to the ultimate end-user such proximity reduces both the latency for Time-to-First-Byte (TTFB) and the Round Trip Time (RTT). Overall, this improves user experience as the content comes through a hair quicker.

Certainly, the other three options for hosting are not slow. They all rely on robust CDN’s with Netlify running on AWS Cloudfront and Vercel running on Google Cloud Platform. While both are strong platforms, they ultimately do not have the latency benefit that the distributed Cloudflare edge network does.

Line art of map

Workers and the Cloudflare CDN

One of the biggest draws to using Cloudflare Pages is the ability to integrate directly with Workers for serverless functions. Workers provides a Chrome V8 isolate environment to execute Javascript/Typescript code for serverless functions, effectively giving a static site a “back-end” even though it’s primarily static HTML. This is great for things like submitting a Contact form, or running a background task to send an email. Effectively, anything which we would typically execute server-side (either for security or because client-side simply isn’t suitable) can be moved over to Workers for execution and triggered with a simple AJAX request. As well, Workers is uniquely positioned in a Pages site in front of the website, allowing Workers to mutate any and all requests inbound.

Furthermore, putting the site on the Cloudflare CDN (regardless of origin host) allows Cloudflare to provide a litany of support services such as DDOS protection, Web Application Firewalling, Caching, TCP optimization. I typically move all my web properties over onto the Cloudflare CDN anyways just because of these features, so it made sense to keep the origin on Cloudflare directly.

The Image Pipeline

As you’ve probably already noticed, this site incorporates a significant amount of high quality and high resolution images, in both the projects and blog post areas. A major challenge arises to both deliver those images to viewers quickly and using as little data as possible, for those on data-capped connections. Additionally, a major hurdle arises for how to store high resolution images, many of which reach over 20MB in size or are at full 8K resolution out of rendering.

Image Pipeline Graphic

The Loading Process

When visiting a page, you may have noticed a slight loading period before any content appears. This is because, by design, the FJLaboratories website does not load any content to the user before it is all prepared. Effectively, this helps to prevent some “jumpy” content when the page loads in some of the content before others. This is accomplished by multiple AJAX requests to the origin fetching images and other content as specified in the post itself before displaying the post. Therefore, speed in processing and delivering most of the images is essential; otherwise, the whole page gets delayed.

Storing of Images

Images for this site are stored on Cloudflare R2 storage. Cloudflare R2 is a distributed object storage system, similar to S3, run on the Cloudflare network. It is mostly S3 compatible, but more importantly, integrates extremely tightly with Cloudflare Workers to provide data manipulation. R2 also comes with a very generous free tier of storage and plenty of operations per month without any egress fees—important when you’re dealing with massive images that get loaded on every page view.

Other options were considered for storing, such as directly in the static site itself. This is what FJLaboratories originally did but it caused some growing pains. With over 2.5GB of high resolution images and videos, the private Git repository was taking up significant space (over 5GB in the commit history) and was becoming a challenge to push-pull effectively in the build process. With the images inside of the Git repository, cloning the repo in each build was taking around 2 minutes, making the ultimate build time to be around 4-5 minutes. However, moving the images out of the repository shorted clone time to 2 seconds and build time to 1 minute, a significant savings in the time-till-live for each update.

As well, other image delivery options weren’t considered, both due to cost as well as lack of integration, compared to Cloudflare’s existing R2 storage. While R2 is in beta, the performance and resiliency of R2 has been very stable in my testing and the latency is more than acceptable. Support has also been great through Cloudflare’s own Discord servers with employees chiming in to help whenever I needed, as well as discuss R2’s roadmap and architecture.

IMGIX Data Center

Photo by imgix on Unsplash

Image Optimization

One of the challenges in dealing with large images is ensuring they do not waste bandwidth. Specifically, for the projects pages, only thumbnails of the images are delivered immediately. When the image is clicked on and opened, only a scaled down and compressed version is delivered. It would be too wasteful of the user’s bandwidth to deliver extremely large images when most would be viewed on devices that can’t reproduce such resolution anyways. Furthermore, hiding full-resolution images behind thumbnails saves bandwidth on the initial load of the page.

Image standards are also rapidly evolving. Browsers now have almost universal support for WebP images and Chromium based browsers such as Chrome and Edge are now rolling out support for AVIF images. This is a major change in the way images are handled in the browser, and as such, it is important to ensure that the images are optimized for the best possible user experience. Long gone are the days of JPEG, and websites should avoid delivering JPEG images if possible.

However, it is difficult to actually generate all these thumbnails and alternative image formats manually. Instead, it is much more efficient of my time to use the Cloudflare Image Resizing to generate the thumbnails and alternative image formats on the fly at request time. This is a great way to save space on the R2 storage since those images are not ultimately stored anywhere. Instead, there is simply a small latency penalty imposed to perform the image optimization and resizing.

Data center patch panel

Photo by Lars Kienle on Unsplash

The Pipeline

The image pipeline on FJLaboratories relies on 2 distinct Cloudflare Workers. The first is a Worker that is responsible for generating the thumbnails and alternative image formats for the images. The second is a Worker that is responsible for pulling the images from the R2 storage.

The following is a snippet of the first Worker which handles the inbound request and performs the resizing of the images. Astute readers may notice that Cache API is implemented but not discussed. We’ll come back to that in the next section. Skip Code

addEventListener("fetch", event => {
  event.respondWith(handleRequest(event))
})

async function handleRequest(event) {
  const request = event.request
  if(request.method == "GET") {
      // Hook in to the cache and have any cache-control headers respected
      const cache = caches.default;
      let resp = await cache.match(request.url + "?" + format)

      if (!resp) {
          resp = await runResize(request)
          if(resp.status == 200) {
              event.waitUntil(cache.put(request.url + "?" + format, resp.clone()))
          }
      } else {
        console.log("Matched")
      }
      return resp
  }

  const resp = await runResize(request)
  return resp
}

// Image Resizing Handler
async function runResize(request) {
  // Parse request URL to get access to query string
  let url = new URL(request.url)

  // Cloudflare-specific options are in the cf object.
  let options = { cf: { image: {} } }

  // Copy parameters from query string to request options.
  // You can implement various different parameters here.
  options.cf.image.fit = "contain"
  if (url.searchParams.has("w")) options.cf.image.width = url.searchParams.get("w")
  if (url.searchParams.has("q")) {
      options.cf.image.quality = url.searchParams.get("q") 
  }

  const accept = request.headers.get("Accept");
  if (/image\/avif/.test(accept)) {
    options.cf.image.format = 'avif';
  } else if (/image\/webp/.test(accept)) {
    options.cf.image.format = 'webp';
  }

  const imageURL = // URL for R2 Worker Endpoint here

  // Build a request that passes through request headers
  const imageRequest = new Request(imageURL, {
    headers: request.headers
  })
  return fetch(imageRequest, options)
}

The snippet for the second worker is displayed below, which is based on kotx/render on Github. Some range-matching has been omitted from the snippet just to keep it short and manageable. Skip Code

interface Env {
  R2_BUCKET: R2Bucket,
  CACHE_CONTROL?: string,
  PATH_PREFIX?: string,
  URL_ENDPOINT?: string
}

type ParsedRange = { offset: number, length: number } | { suffix: number };

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const allowedMethods = ["GET", "OPTIONS"];
    if (allowedMethods.indexOf(request.method) === -1) return new Response("Method Not Allowed", { status: 405 });

    if (request.method === "OPTIONS") {
      return new Response(null, { headers: { "allow": allowedMethods.join(", ") } })
    }

    const url = new URL(request.url);
    if (url.pathname === "/") {
      return new Response("OK");
    }

    const cache = caches.default;
    let response = await cache.match(request);

    let range: ParsedRange | undefined;

    if (!response || !response.ok) {

      const endpointLen = ; // Needed to extract path in next step
      
      const path = ; // Extract file path from request URL here;

      let file: R2Object | R2ObjectBody | null | undefined;

      if (ifMatch || ifUnmodifiedSince) {
        file = await env.R2_BUCKET.get(path, {
          onlyIf: {
            etagMatches: ifMatch,
            uploadedBefore: ifUnmodifiedSince ? new Date(ifUnmodifiedSince) : undefined
          }, range
        });

        if (file && !hasBody(file)) {
          return new Response("Precondition Failed", { status: 412 });
        }
      }

      if (ifNoneMatch || ifModifiedSince) {
        // if-none-match overrides if-modified-since completely
        if (ifNoneMatch) {
          file = await env.R2_BUCKET.get(path, { onlyIf: { etagDoesNotMatch: ifNoneMatch }, range });
        } else if (ifModifiedSince) {
          file = await env.R2_BUCKET.get(path, { onlyIf: { uploadedAfter: new Date(ifModifiedSince) }, range });
        }
        if (file && !hasBody(file)) {
          return new Response(null, { status: 304 });
        }
      }

      file = request.method === "HEAD"
        ? await env.R2_BUCKET.head(path)
        : ((file && hasBody(file)) ? file : await env.R2_BUCKET.get(path, { range }));

      if (file === null) {
        return new Response("File Not Found", { status: 404 });
      }

      response = new Response((hasBody(file) && file.size !== 0) ? file.body : null, {
        status: range ? 206 : 200,
        headers: {
          // Headers tagging, see kotx/render for full code
        }
      });

      if (request.method === "GET" && !range)
        ctx.waitUntil(cache.put(request, response.clone()));
    }

    return response;
  },
};

Speedups and Operations

Like mentioned earlier, while running Image Resizing on Cloudflare’s Workers Platform provides great benefits in terms of reducing storage complexity and preparation workload, it adds a modest latency penalty to each image. It appears that the penalty right now caps at around 1 second, but has its 95th percentile at around 200ms. This isn’t a huge penalty at the end of the day, but does provide a noticeable difference in performance.

Additionally, both Image Resizing and R2 Storage are billed in terms of operations. Therefore, calling both Image Resizing (to resize an image) and R2 (to fetch the raw image) more frequently puts a strain on the billing for both. Therefore, with both latency and billing in mind, the goal is to try to hit either R2 or Image Resizing as little as possible.

To accomplish this, I’m utilizing Cloudflare’s Cache API to store arbitrary data into CF’s edge cache. Both the Image Resizing Worker and the R2 Fetching Worker will dump their results into the Edge Cache so subsequent requests can immediately be fulfilled through there. This avoids repeated requests for the same assets being sent back down to Image Resizing and R2.

Long exposure of city

A note about the Edge Cache though: the Edge Cache is localized to each colocation center. Therefore, viewers from different geographic regions, even if visiting right after one another, will not hit the cache. Therefore, some modest amount of billing is still incurred.

Additionally, Cache API is quite ephemeral. The data stored in the Edge Cache is often evicted within five minutes if not accessed again. Discussions internally with Cloudflare solutions engineers suggest that this is by design and that some file types such as images get evicted sooner due to their larger size. On slower traffic days, analytics show that approximately 10% of image requests are served from caches. However, on higher traffic days (like immediately after a Reddit post), about 60% of images are being served through the Cache. This suggests that Cache really is doing its intended job—minimizing billing when on high traffic spikes while reducing latency. Hopefully, the eviction of assets will be improved upon the release of Cache Reserve which stores cached assets in long-lived R2 buckets.

The Search Pipeline

Due to the large number of projects and my frequent need to refer back to them, I found that implementing a robust search functionality was critical for my own usability. One of my key requirements was that I did not want the search to take place server-side, since this was a purely static site after all. Therefore, I decided to implement a search pipeline that runs exclusively on the client-side after initial load and even functions offline.

Search Pipeline Graphic

The Search Platform

Upon investigating some client-side search libraries—I was not about to write my own—lunr.js caught my attention. Lunr is a JavaScript library that allows you to search through a JSON dataset. It’s a very simple library, and it’s easy to use. It’s also very performant. It’s built similar to Solr (an extremely robust and popular search library) but is might more lightweight and suitable for small data sets.

Lunr operations on the concept of a “search index” which is a structured data set containing data that Lunr will search against. Once Lunr is initialized on an index, calling a Lunr search is as simple as:

idx.search("query");

Lunr will return a result with the reference of the document that matched the query as well as the match score. The reference can then be used to lookup on the index the exact record that matched the query and create a results list. Skip Code

( () => { // Search scoping function

// ====== HANDLE SEARCH INDEX ======
let idx = null;
// Load Search Index JSON file after the page load is complete. Do not load until then to avoid blocking.
window.addEventListener('load', (event) => {
    // Do not load index if already exists in session storage
    // Use Promise so that Lunr index builds only after the JSON file is downloaded
    let getIndexPromise = new Promise((resolve, reject) => {
        if (sessionStorage.getItem("searchindex") == null) {
            let index;

            // Setup AJAX request for searchindex
            let downloadindex = new XMLHttpRequest();
            downloadindex.open("GET", "/searchindex.json");

            downloadindex.onreadystatechange = function() {
                if (this.readyState === 4 && this.status  === 200) {
                    index = JSON.stringify(this.responseText);
                    // Put the index into session storage so subsequent browsing doesn't need to grab it again.
                    sessionStorage.setItem("searchindex", index);
                    resolve();
                } else if (this.readyState === 4 && this.status !== 200) {
                    reject();
                } else {
                    return;
                }
            };
            downloadindex.send();
        } else {
            resolve();
        }
    });

    // Build out the Lunr index immediately after AJAX complete
    getIndexPromise.then(
        () => {
            idx = lunr(function() {
                // Needs double parse for some reason
                let indexObject = JSON.parse(JSON.parse(sessionStorage.getItem("searchindex")));
                // Use the URL as the doc locator
                this.ref("url");
                // Search within body/description/title
                this.field("body");
                this.field("description");
                this.field("title", {boost: 10}); // Weight searches on the title most important
        
                indexObject["posts"].forEach(function (doc) {
                    this.add(doc);
                }, this);
            });
        },
        () => { console.error("Error: SearchIndex Failed to GET"); }
    );
});

// UI operations omitted

// ====== HANDLE SEARCHING AND RESULTS ======
// Perform live searching as the search bar gets input
( () => { // Another scoping function to keep things contained
    let searchBar = document.getElementById("search-field");

    // Start listening to search inputs
    searchBar.addEventListener('input', (event) => {
        let searchResults = null;
        let searchText = searchBar.value;

        // Only trigger searching when there are more than 4 characters in the search bar, otherwise it doesn't make sense to search through fuzzy since it'll pick up too much
        if ( searchText.length >= 4 ) {
            // Append text to search terms
            const appendToSearch = (str, text = "~1") => {
                return str
                .split(" ")
                .map(word => `${word}${text}`)
                    .join(" ");
            };

            // Run the actual search through Lunr and pull the results
            searchResults = idx.search(appendToSearch(searchText));

            // Overall object for adding search results to the DOM
            function searchResultUpdate(reference, score, count) {
                this.ref = reference;
                this.score = score;
                this.count = count;

                this.addToDOM = function(ref = this.ref, score = this.score, count = this.count) {
                    if (score <= 1.2) {
                        return false;
                    }
                    // Define function to actually pull the entry from the search index
                    let getData = (ref) => {
                        // Bring up the Search Index from Session Storage
                        let indexObject = JSON.parse(JSON.parse(sessionStorage.getItem("searchindex"))).posts;
                        // Filter and pull the correct data
                        const getEntry = (data, code) => {
                            return data.filter(
                                (data) => data.url == code
                            );
                        };
                        // Return the result from the search for the entry
                        const filteredResult = getEntry(indexObject, ref)[0];
                        return filteredResult;
                    };

                    // Define function for building out the entry and adding it to the DOM
                    let addEntry = (doc) => {
                        // Pull entry info
                        const searchEntryID = "#search-result-" + count;
                        const image = doc.image;
                        const title = doc.title;
                        const url = doc.url;
                        const body = doc.body.substring(35,400); // 365 characters is a good ballpark

                        // Create Appending String for the DOM
                        const appendContent = // Add content to DOM

                        // Actually append the string into the search content div
                        $(document).ready(function() {
                            $(appendContent).appendTo("#search-content");
                        });
                    }
                    let entry = getData(ref); 
                    addEntry(entry);
                }
            };

            document.getElementById("search-content").innerHTML = "";
            searchResults.every((element, index) => {
                // Create the searchResult object
                let resultEntry = new searchResultUpdate(element.ref, element.score, index);

                // Call the function to add the result entry to the actual DOM
                resultEntry.addToDOM();

                if (index > 3) {
                    return false;
                } else {return true;}
            });
        } else {
            document.getElementById("search-content").innerHTML = "";
        }
    });
})(); 
})(); 

Magnifying glass over a book

Photo by Mick Haupt on Unsplash

The Search Index

As Lunr operates on the concept of a search index, which is a document containing all information be searched, one of those must be generated and served with the site. Thankfully, since Jekyll is a very flexible static site generator, creating an index is easy. Simply needed was the inclusion of a template JSON file that Jekyll is able to loop over during build-time.

    "posts": [
    {%- for page in site.posts -%}
        {
            "title": "{{ page.title }}",
            "description": "{%- if page.description -%}{{ page.description | strip_html | xml_escape | normalize_whitespace }}{%- elsif page.subtitle -%}{{ page.subtitle | strip_html | xml_escape | normalize_whitespace }}{%- else -%}{{ page.excerpt | strip_html | xml_escape | normalize_whitespace}}{%- endif -%}",
            "body": {{ page.content | strip_html | xml_escape | strip_newlines | escape | normalize_whitespace | jsonify }},
            "url": "{{ page.url | absolute_url }}",
            "image": "
                // Image URL Block
            "
        },
    {% endfor %}
    ]

While merely a plain text file, this can end up quite large as it contains the text of every single entry in the site. Currently, at the time of writing, this file is approximately 46kB. Therefore, it’s important for user bandwidth and page speed to reduce the requests for this file to as few as possible. However, it’s also extremely important to keep this file up to date as new entries are added to the site.

A compromise was selected to store this file in Session Storage. Session Storage is a way to store data on the user’s computer that is not persisted across sessions. This means that the data is only available to the current session. This is a good choice for this file as it is only used for searching, and is not used for anything else. It’s entirely fine is this file is lost once the user closes the browser. However, Session Storage allows the file to persist across multiple page views reducing the number of needed re-downloads. As well, if a user wished to get a new copy of the search index, all they would need to do is close the tab and open a new one.

The search code above first checks for the presence of the index in the Session Storage and if it’s not found, performs an AJAX request for the index file. The file is then stored into Session Storage.

Conclusion

This post showcased some of the pipelines used on this site. This is hardly a complete look at everything that went into building this site and ensuring its performance. Look out for more posts like this where I delve into other elements of the site such as performance optimizations, unit testing, and visual testing.

Cover Photo by Shubham Dhage on Unsplash