TIL: How to configure CloudFront Functions for SPA

TIL: How to configure CloudFront Functions for SPA
Photo by traf / Unsplash

Last week, we audited parts of our old infrastructure and found many outdated configurations. They were working, but could be doing better.

We had a strange chain of CloudFront → Nginx → S3 bucket. Nginx was doing some complex redirects that were necessary at that time, as the single-page application was gradually covering old pages. On top of this chain, I found an AWS Lambda attached to the Viewer Response. It was also doing some redirects. Yeah, when people leave the company, they take some knowledge with them, and you need to be very good at technical handoff to keep this knowledge.

Since 2020, when this part of the infrastructure was deployed, CloudFront has released several interesting features.

Feature 1. CloudFront Functions

This is exactly what you need to do simple redirects or HTTP header modifications.

Introducing CloudFront Functions – Run Your Code at the Edge with Low Latency at Any Scale | Amazon Web Services
With Amazon CloudFront, you can securely deliver data, videos, applications, and APIs to your customers globally with low latency and high transfer speeds. To offer a customized experience and the lowest possible latency, many modern applications execute some form of logic at the edge. The use cases…

Single page applications do the virtual routing, meaning that the JS app handles all the routing and the actual page files do not exist on disk. So all you need is to rewrite all requests like www.example.com/auth/login/ to s3://your-bucket-name/index.html, and the JS app will load and handle the rest in the browser.

Our app lives in a subroute, so the CloudFront function can look like this

function handler(event) {
    var request = event.request;
    var uri = request.uri;
    
    // Check if URI is missing an extension (like .js, .css, .html)
    if (!uri.includes('.')) {
        // Rewrite to SPA root index.html
        request.uri = '/index.html'; 
    }
    
    return request;
}

It rewrites all requests to index.html in the root of the bucket. The important thing here is to decide what caching strategy you want for this file. You need to set it at upload time.

The recommended setup is to always return a fresh version of index.html, meaning you will have Cache-Control: max-age=0. However, there is an awesome feature that I didn’t know about: s-maxage.

Cache-Control header - HTTP | MDN
The HTTP Cache-Control header holds directives (instructions) in both requests and responses that control caching in browsers and shared caches (e.g., Proxies, CDNs).

Cache-Control metadata for index.html can be: public, max-age=0, s-maxage=86400, must-revalidate

CloudFront respects this and can return a cached version for some time while doing a refresh request in the background. The user’s browser won’t cache the file because it respects the max-age directive. You need to be careful with how you deploy the app and execute invalidation requests in order to keep the previous index.html content available for some time.

Feature 2. CloudFront Key Value Store (KVS)

Nginx had some rules to easily enable or disable maintenance mode. CloudFront KVS can do this for you. Both Functions and Key Value Store are designed to be blazingly fast and have a microseconds-level budget, so they will not affect your latency as much as you might think.

Introducing Amazon CloudFront KeyValueStore: A low-latency datastore for CloudFront Functions | Amazon Web Services
December 12, 2023: Post updated to clarify that when a CloudFront function changes the uri value, it doesn’t change the cache behavior for the request or the origin that an origin request is sent to. Amazon CloudFront allows you to securely deliver static and dynamic content with low latency and hig…
Amazon CloudFront KeyValueStore - Amazon CloudFront
Learn how to work with key value stores and key-value pairs that you use with CloudFront Functions.

In the Use Cases section, AWS docs mention that it is useful for A/B testing, rewrites, and handling auth-based locations. Maintenance, broadly speaking, is just a subset of A/B testing. So you can easily add a variable like maintenance-mode and read it on each request. Yes, it comes at a certain price, so be mindful of the number of requests coming to your app.

So your function can look like this

import cf from 'cloudfront';

const kvsId = '<KEY_VALUE_STORE_ID>';

// If the KeyValueStore isn't associated with this function, creating the handle can fail.
// In that case we fall back to behaving as if maintenance mode is off.
let kvsHandle = null;
try {
  kvsHandle = cf.kvs(kvsId);
} catch (e) {
  kvsHandle = null;
}

async function handler(event) {
  const request = event.request;
  const uri = request.uri || '/';

  // Rule 1: Requests to "static files" (last path segment contains an extension) pass through unchanged,
  // even if maintenance mode is enabled.
  const lastSegment = uri.split('/').pop() || '';
  const hasExtension = lastSegment.includes('.') && !lastSegment.endsWith('.');
  if (hasExtension) {
    return request;
  }

  // Rule 2: Check maintenance flag from CloudFront KeyValueStore (only for non-static routes)
  const key = 'maintenance-mode';
  let maintenanceOn = false;

  if (kvsHandle) {
    try {
      const raw = await kvsHandle.get(key);
      // Not sure how the key will be stored in kvs, need to check
      maintenanceOn = String(raw).trim().toLowerCase() === 'true';
    } catch (err) {
      // If there's an error, treat it as maintenance off (per your requirement)
      console.log(`KVS lookup failed for ${key}: ${err}`);
      maintenanceOn = false;
    }
  }

  // Rule 3: Rewrite to index.html by default, or maintenance page if flag is true
  request.uri = maintenanceOn ? '/maintenance/index.html' : '/index.html';

  return request;
}

export { handler };

Associate this function with Viewer Request and that's it.