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
)
As you navigate around the app, the URL bar updates to reflect the client-side routing location, without loading additional files from the server:
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.
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.
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
and404
errors. Customise the response to returnindex.html
instead, and change the status to200
.
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:
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!
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:
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:
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
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:
When you click Deploy on this page, AWS will publish the lambda as version 1, and everything should be configured and running!
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!
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:
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 ARNarn: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