In this post I show how to a handle an issue you get when hosting a SPA on AWS with S3 and CloudFront, where reloading the app gives a 403 or 404 response. I'll show how to use Lambda@Edge to handle this by returning the default document (index.html) for the app instead.

Deploying a SPA as static files - the problem with page reloads

One of the plus-sides of using a SPA framework like Angular is that the app consists of only static files (HTML and JavaScript). These files can be deployed to static file hosting and cached using a CDN. If you're deploying to AWS, the obvious choices are S3 and CloudFront.

There's just one problem with this. Many SPA frameworks (including Angular) use client-side routing. The app itself is loaded when you load the home page (e.g. /, or /index.html)

Image of a sample app loading at the route

As you navigate around the app, the URL bar updates to reflect the client-side routing location, without loading additional files from the server:

Image of client-side routing

Unfortunately, the fact the URL updates in this way can cause problems if you are hosting your app as static files on a service like S3. If you reload the page (by pressing F5 for example), the browser will make a request to the server for the document at the provided URL. For example, reloading the page in the image above would send a request to /detail/13.

Unfortunately, there _is_ no file /detail/13 or /detail/13/index.html on the server, so instead of loading the detail page above, you'll get a 404 Not Found error.

404 when reloading a page

This isn't just a problem for page reloads with F5, you get the same problem when trying to navigate to a page by typing a URL in the address bar directly.

The solution to this problem is to intercept these 404 responses on the server, and return the default document for the app (/index.html) instead. This will bootstrap the app, and then subsequently navigate to the /detail/13 route on the client-side.

Image of the solution returning the default document for unknown requests

Achieving this behaviour requires server-side logic, so your CDN or static file hosting must support it.

The simple solution with S3 and CloudFront

The good news is that if you're hosting your SPA files on S3 and using CloudFront as a CDN, the functionality you need is built in for simple cases. I won't go into details here, as this medium article explains it very well, but in summary:

  • Add a new distribution with the S3 bucket hosting your app's files as an origin.
  • Edit the settings for the distribution.
  • Add CustomErrorResponses for both 403 and 404 errors. Customise the response to return index.html instead, and change the status to 200.

This will work for most cases, where you are hosting a single app within a distribution.

However, it's possible to host multiple apps at different paths in CloudFront, and to have separate behaviours for each:

Multiple behaviours hosting multiple apps in CloudFront

CustomErrorResponses are set at the distribution level, so they're no good in the situation above. Instead you need a different custom error behaviour for each separate app (hosted at /customers.app/ and /admin.app/). You can achieve this using AWS Lambda functions deployed to CloudFront, called Lambda@Edge.

For the remainder of this post I'll describe how to setup Lambda@Edge to handle the 404 (and 403) responses by returning the appropriate index.html for each app, such as /customers.app/index.html.

Using Lambda@Edge to customise error responses

As described in the documentation:

Lambda@Edge lets you run Lambda functions to customize content that CloudFront delivers, executing the functions in AWS locations closer to the viewer. The functions run in response to CloudFront events, without provisioning or managing servers. You can use Lambda functions to change CloudFront requests and responses

The first step is to create a new AWS Lambda function. There's many ways to do this, but I just created one by clicking Create Function in the us-east-1 web console:

Important: Make sure your region is set to us-east-1 (N. Virginia), even if that's not your usual region. Lambda functions for use with Lambda@Edge must use this region!

The functions list in the web console

From the following page, choose Author from scratch, add a name for your function, select the Node.js 8.1.0 runtime environment, and configure (or create) a role that will be used to execute the Lambda function:

Creating a new function using the web console

You can now create your Lambda function. I'll go through the JavaScript in the next section.

The Lambda function

In this section I'll go through the Lambda function logic. First I'll present the whole function and then go through it bit-by-bit:

'use strict';

const http = require('https');

const indexPage = 'index.html';

exports.handler = async (event, context, callback) => {
    const cf = event.Records[0].cf;
    const request = cf.request;
    const response = cf.response;
    const statusCode = response.status;

    // Only replace 403 and 404 requests typically received
    // when loading a page for a SPA that uses client-side routing
    const doReplace = request.method === 'GET'
                    && (statusCode == '403' || statusCode == '404');

    const result = doReplace 
        ? await generateResponseAndLog(cf, request, indexPage)
        : response;

    callback(null, result);
};

async function generateResponseAndLog(cf, request, indexPage){

    const domain = cf.config.distributionDomainName;
    const appPath = getAppPath(request.uri);
    const indexPath = `/${appPath}/${indexPage}`;

    const response = await generateResponse(domain, indexPath);

    console.log('response: ' + JSON.stringify(response));

    return response;
}

async function generateResponse(domain, path){
    try {
        // Load HTML index from the CloudFront cache
        const s3Response = await httpGet({ hostname: domain, path: path });

        const headers = s3Response.headers || 
            {
                'content-type': [{ value: 'text/html;charset=UTF-8' }]
            };

        return {
            status: '200',
            headers: wrapAndFilterHeaders(headers),
            body: s3Response.body
        };
    } catch (error) {
        return {
            status: '500',
            headers:{
                'content-type': [{ value: 'text/plain' }]
            },
            body: 'An error occurred loading the page'
        };
    }
}

function httpGet(params) {
    return new Promise((resolve, reject) => {
        http.get(params, (resp) => {
            console.log(`Fetching ${params.hostname}${params.path}, status code : ${resp.statusCode}`);
            let result = {
                headers: resp.headers,
                body: ''
            };
            resp.on('data', (chunk) => { result.body += chunk; });
            resp.on('end', () => { resolve(result); });
        }).on('error', (err) => {
            console.log(`Couldn't fetch ${params.hostname}${params.path} : ${err.message}`);
            reject(err, null);
        });
    });
}

// Get the app path segment e.g. candidates.app, employers.client etc
function getAppPath(path){
    if(!path){
        return '';
    }

    if(path[0] === '/'){
        path = path.slice(1);
    }

    const segments = path.split('/');

    // will always have at least one segment (may be empty)
    return segments[0];
}

// Cloudfront requires header values to be wrapped in an array
function wrapAndFilterHeaders(headers){
    const allowedHeaders = [
        'content-type',
        'content-length',
        'last-modified',
        'date',
        'etag'
    ];

    const responseHeaders = {};

    if(!headers){
        return responseHeaders;
    }

    for(var propName in headers) {
        // only include allowed headers
        if(allowedHeaders.includes(propName.toLowerCase())){
            var header = headers[propName];

            if (Array.isArray(header)){
                // assume already 'wrapped' format
                responseHeaders[propName] = header;
            } else {
                // fix to required format
                responseHeaders[propName] = [{ value: header }];
            }    
        }

    }

    return responseHeaders;
}

The handler function exported at the top of the file is what CloudFront will call when it gets a response from S3. The first thing the handler does is check if the response is a GET request and a 404 or a 403. If it is, we'll generate a new response by calling generateResponseAndLog, otherwise we use the existing response.

generateResponseAndLog() calculates the path for returning the default document by combining the original request domain, the first segment of the URL (admin.app in /admin.app/detail/13), and the default document.

generateResponse() makes a GET request to S3 for the index.html (from the same CloudFront distribution, as we reused the same domain) and converts it into the correct format. Not all headers are allowed, and they have to be added to an object using the following format, so wrapAndFilterHeaders() handles that

{
  "header-name": [{ "value": "header-value"}]
}

Finally, the response is sent with a 200 Ok status code, including the filtered and wrapped headers, and the body of the index.html file.

Testing the lambda function

The Lambda web console includes facilities for testing your function. You can create a test event and invoke your function with it. For examples of the event structure, see the documentation. For example, the following request represents the Response event received by Lambda@Edge after receiving a 404 from the underlying Origin (S3):

{
  "Records": [
    {
      "cf": {
        "config": {
          "distributionDomainName": "d12345678.cloudfront.net",
          "distributionId": "EXAMPLE"
        },
        "request": {
          "uri": "/admin.app/details/13",
          "method": "GET",
          "clientIp": "2001:cdba::3257:9652",
        },
        "response": {
          "status": "404",
          "statusDescription": "Not Found"
        }
      }
    }
  ]
}

As this event contains a 404 response, the function should convert it into a 200 response:

Function test execution successsful

Once you're happy with the function, you can deploy it to Lambda@Edge.

Deploying the function to Lambda@Edge

To deploy the function to CloudFront, choose Actions, and then Deploy to Lambda@Edge

Creating a new function using the web console

If you don't see Deploy to Lambda@Edge in the drop down, you've probably created the Lambda in the wrong region. Remember, you have to create your functions in the us-east-1 region.

After clicking Deploy… you're presented with the deployment options. Choose the distribution for your apps and select the correct behavior for your app. Make sure to set the CloudFront event to Origin response, i.e. the event after the Origin responds but before it sends the response to the user:

Deploying to Lambda@Edge

When you click Deploy on this page, AWS will publish the lambda as version 1, and everything should be configured and running!

Deploy complete

You can test it by reloading your app on a detail page, and if all is set up right, you'll be presented with a functioning app!

Client-side routing working correctly

Bonus: Updating the Lambda@Edge

When you deploy your Lambda, it's automatically configured inside the CloudFront behaviour. You can see the registration yourself by going to CloudFront Distributions > Distribution > Behaviours > Edit Behaviour > Lambda Function Associations:

Updating the Lambda association manually

This can be useful if you want to update the Lambda function definition. To do so, you need to publish a new version of the Lambda, and then update the ARN in CloudFront. The last number in the ARN is the version:

  • arn:aws:lambda:aws-region:acct-id:function:helloworld - Unversioned ARN
  • arn:aws:lambda:aws-region:acct-id:function:helloworld:3 - Published ARN (version 3)
  • arn:aws:lambda:aws-region:acct-id:function:helloworld:$LATEST - Unpublished ARN

Resources