Serving Localised Assets from S3 Using Lambda@Edge
⚠️ This post was last updated in 2020, meaning its contents may be outdated.
Using Lambda@Edge, you can route users to different assets depending on their browser language, country or device (to name a few). In this post I am going to focus on the Accept-Language
header, to detect the user's locale.
Demo
Example image served using this technique:
Depending on your browser language, you will see:
- "GB" for "English (United Kingdom)" (region-test.png.en-GB)
- "US" for "English (United States)" (region-test.png.en-US)
- "CA" for "English (Canada)" (region-test.png.en-CA)
- "EN" for "English" (region-test.png.en)
- "GLOBAL" if set to anything else (region-test.png)
Use Cases
A few use cases for asset routing based on the caller's locale:
- Localising a date in an image, so that requesters from the USA see mm-dd-yy, rather than the global dd-mm-yy
- Serving a different asset for a different region - for example for a game, showing a box-art varient without blood for users with the de-DE locale
- Serving an asset with text in a different language
Overview
This code uses Lambda@Edge on a CloudFront distribution sat over the S3 bucket. The Lambda will be invoked on the "Origin Request" event, meaning that the result will be cached for users that have the same Accept-Language
header:
- Incoming request from the user hits the CloudFront edge
- The CloudFront edge checks its cache for the
URI + Accept-Language
response - If it has a cache hit, it serves the response from its cache and does not invoke the Lambda function
- If it has a cache miss, it invokes the Lambda function before it requests the URI from the origin
- The Lambda function re-writes the URI to a locale specific asset, depending on whether one exists
- The CloudFront edge requests the URI from S3, adds it to its cache as
URI + Accept-Language
, and serves the content to the user
Lambda Setup
The Lambda function itself should be a Node.JS Lambda function in us-east-1
. Here is the code I am using, which depends on the NPM package accept-language-parser:
'use strict';
const http = require('http');
const languageParser = require('accept-language-parser');
exports.handler = (event, context, callback) => {
let request = event.Records[0].cf.request;
const hostHeader = request.headers['host'][0].value;
const acceptLanguageHeader = request.headers['accept-language'];
// If no header set, serve original URI
if (!acceptLanguageHeader) {
callback(null, request);
return;
}
// Pull out the value of the Accept-Language header, and normalise it
const acceptLanguage = acceptLanguageHeader[0].value.toLowerCase();
// Parse the value of the header, if no languages set serve original URI
const languages = languageParser.parse(acceptLanguage);
if (languages.length === 0) {
callback(null, request);
return;
}
// Pull out the first language
const language = languages[0];
// Construct a suffix, en-GB for example
const assetSuffix = language.code + (language.region ? '-' + language.region.toUpperCase() : '');
console.log("Using asset suffix " + assetSuffix);
// Append the asset suffix to the URI - /test.png becomes /test.png.en-GB
const assetPath = request.uri + '.' + assetSuffix;
console.log("Calling HEAD " + assetPath);
// Construct a HEAD request to check if such an asset exists
const headRequest = {
method: 'GET',
host: hostHeader,
port: 80,
path: assetPath
};
// Send the request, if successful adjust the URI
http.get(headRequest, res => {
if (res.statusCode === 200) {
console.log("Adjusting origin URI to " + assetPath);
request.uri = assetPath;
callback(null, request);
return;
}
console.log("Resource not found, using original URI");
callback(null, request);
}).on('error', e => {
console.log("Error calling S3, using original URI");
callback(null, request);
});
};
The code does the following:
- Parses the incoming
Accept-Language
header using accept-language-parser - Adjusts the URI to the origin to add the language code and region as a suffix
For example,
/test.png
becomes/test.png.en-GB
for a user in the UK 3. Makes aHEAD
request to the original origin, using HTTP (HTTPS did not seem to work) 4. If the result is200 OK
, re-writes the user's URI to the locale-specific URI
Once you have defined and tested your Lambda function, publish a version and copy its ARN:
arn:aws:lambda:us-east-1:999999999999:function:CloudFrontRegionRouter:1
CloudFront Setup
The CloudFront distribution should have a behaviour set up with the following options:
- The Origin set to S3, or some other static content provider that supports HTTP requests
- Whitelist Headers set to
Accept-Language
- CloudFront Event set the event type to "Origin Request" and the above Lambda version ARN
- TTLs ensure these are not zero so that the result of the Lambda function is cached
For the TTL, you can pick a year for example if your assets will never change.
Performance Considerations
The Edge lambda has to make a request to S3 before deciding what to do, and this is obviously an overhead. The redeeming factor here is that the request is only made once for each unique language.
If you don't want to make the HEAD
request in the Lamda function you could also define a static mapping between assets and languages, but this gives you less flexibility in that you have to keep the Lambda function up to date with the static content that is available.
Further Reading
This technique can be used with any header available in the CloudFront request. Here were some resources useful for completing this exercise:
🏷️ lambda asset cloudfront origin uri s3 lambda@edge language region-test png cache header locale english code
Please click here to load comments.