Serving Localised Assets from S3 Using Lambda@Edge

Posted May 17th, 2020 in cloud-software

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:

The text 'GB' for users visiting from the UK, 'US' from the US, 'CA' from Canada, 'EN' for other English speakers and 'GLOBAL' for anyone else.

Depending on your browser language, you will see:

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:

  1. Incoming request from the user hits the CloudFront edge
  2. The CloudFront edge checks its cache for the URI + Accept-Language response
  3. If it has a cache hit, it serves the response from its cache and does not invoke the Lambda function
  4. If it has a cache miss, it invokes the Lambda function before it requests the URI from the origin
  5. The Lambda function re-writes the URI to a locale specific asset, depending on whether one exists
  6. 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:

  1. Parses the incoming Accept-Language header using accept-language-parser
  2. 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 a HEAD request to the original origin, using HTTP (HTTPS did not seem to work) 4. If the result is 200 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:

Comments