Gaurab Paul

Polyglot software developer & consultant passionate about web development, distributed systems and open source technologies

Support my blog and open-source work

Tags

Configuring cloudfront functions for SPA routing with CDK
Posted  a year ago

When building single page applications, it is convenient to serve the complete website including the HTML files from a CDN like AWS cloudfront. All the assets can then be potentially served from a location close to the user. This works particularly well for PWAs and dynamic client rendered websites.

It is also common to use push based routing in single page applications. However the first request would always go the server so we need to setup some server side routing as well to route these requests to an appropriate HTML file. In the simplest case we'd route all incoming requests to our domain to a single index.html file, and the javascript referenced in the HTML file will take over once the browser renders it.

This is easily accomplished via cloudfront functions, which are a recently introduced cost effective alternative to lambda@edge.

lambda@edge may be more suitable if you need to execute complex logic and need access to a more full-fledged execution environment like node.js (For example if you are doing server side rendering). However for simpler use cases like changing routes, adapting headers etc. cloudfront functions offer a simpler and more cost effective alternative.

Here is a simple function to route all requests which don't have an extension in the url to index.html:

// path-redir-rule.js
function handler(event) {
    var request = event.request
    var hasExtension = request.uri.includes('.')
    if (!hasExtension) {
        request.uri = '/app/index.html'
    }
    return request;
}

Because we love IaC, we will use CDK to wire up our cloudfront. This post is not intended to be a good first intro to CDK, but here are a few if you are using it for first time: [1], [2].

It should not surprise anyone that AWS CDK has good support for AWS Cloudfront.

Here is a simple stack that uses CDK with typescript to wire up a cloudfront stack backed by an S3 bucket.

import path from "node:path"
import * as cdk from "aws-cdk-lib";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as cf from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";

export class FrontendStack extends cdk.Stack {
  publicAssetsS3Bucket = new s3.Bucket(this, "PublicAssetsBucket", {
    removalPolicy: cdk.RemovalPolicy.RETAIN,
    publicReadAccess: true,
    websiteIndexDocument: "index.html",
    versioned: false,
  });

  this.cfOrigin = new origins.S3Origin(this.publicAssetsS3Bucket);

  this.cfDistribution = new cf.Distribution(this, "CFDistribution", {
    defaultBehavior: {
      origin: this.cfOrigin
    }
  });
}

In a production application we will also configure certifications and domains for the distribution, which I have omited to keep the post focussed, but here is another post that convers those things too.

We can now update this CF distribution configuration to use our function.

export class FrontendStack extends cdk.Stack {
publicAssetsS3Bucket = new s3.Bucket(this, "PublicAssetsBucket", {
removalPolicy: cdk.RemovalPolicy.RETAIN,
publicReadAccess: true,
websiteIndexDocument: "index.html",
versioned: false,
})
cfPathRedirFunction = new cf.Function(this, "PathRedirFunction", {
code: cf.FunctionCode.fromFile({
filePath: path.join(
__dirname,
"./cf-functions/path-redir-rule.js"
),
}),
});
this.cfOrigin = new origins.S3Origin(this.publicAssetsS3Bucket);
this.cfDistribution = new cf.Distribution(this, "CFDistribution", {
defaultBehavior: {
origin: this.cfOrigin,
functionAssociations: [
{
function: this.cfPathRedirFunction,
eventType: cf.FunctionEventType.VIEWER_REQUEST,
}
],
}
});
}

Since this function will need to be run before the target is selected, we needed to use VIEWER_REQUEST event type.

We can also consider adding a response function which adds headers to prevent the browser from caching our html pages, as we can expect it to frequently change.

// prevent-html-caching.js

function handler(event) {
    var request = event.request
    var parts = request.uri.split('/')
    var lastPart = parts[parts.length-1]
    var response = event.response;
    var headers = response.headers;
    if (lastPart.match(/\.html$/) || lastPart.match(/^[^.]*$/)) {
        headers['cache-control'] = { value: 'no-cache' }
    }
    return response
}

Because this function needs access to the response being sent, the function event type needs to be VIEWER_RESPONSE.

export class FrontendStack extends cdk.Stack {
// ...
cfHtmlRespFunction = new cf.Function(this, "HTMLRespFunction", {
code: cf.FunctionCode.fromFile({
filePath: path.join(
__dirname,
"./cf-functions/prevent-html-caching.js"
),
}),
});
this.cfOrigin = new origins.S3Origin(this.publicAssetsS3Bucket);
this.cfDistribution = new cf.Distribution(this, "CFDistribution", {
defaultBehavior: {
origin: this.cfDistribution,
functionAssociations: [
{
function: this.cfPathRedirFunction,
eventType: cf.FunctionEventType.VIEWER_REQUEST,
},
{
function: this.cfHtmlRespFunction,
eventType: cf.FunctionEventType.VIEWER_RESPONSE,
},
],
}
});
}

And that is it. Run cdk synth and cdk deploy to deploy or update your cloudfront setup.