Full working typescript example can be found on my GitHub

The Need

At Voxi we have a multi-domain progressive web app for end user access. We want one application that can have customizeable subdomains as this allows us to only write the application once but customize each experience based on the show. I have been migrating this application from ASP.NET to a static site using React & AWS AppSync. However, when sharing the site via social media, Slack, etc, we need to change the metadata based on URL. In comes AWS Cloudfront server side rendering using Lambda@Edge!

The Struggle

My use case is not exactly supported by AWS. A static site isn’t really meant to change or vary, so many of the headers for caching are unsupported by default when using S3 as the origin. However, you can “trick” Cloudfront by changing your origin from an S3 Origin to a Custom Origin and forcing it to use the Host header. Unfortunately, when you do this, it breaks the origin

The Solution

There are two main options available when using S3 as the origin: 1. You can have your Lambda@Edge function trigger off of Viewer Request. This is simplest, but it won’t cache your modified content and will trigger on every request. Example here 1. You can have your Lambda@Edge function trigger off of BOTH Origin Request and Origin Response. This is more annoying and requires us to rewrite but does give us caching instead of triggering the function on every user request.

Ultimately I decided to go with option #2 so that I could benefit from some levels of caching.

Modify the site

Modify your site’s index.html so that it has tokens that we can replace. I went with the style of __OG_TITLE. Example:

<meta property="og:image" content="__OG_IMAGE__" />
<meta property="ogalt" content="SSR Demo Image" />
<meta property="og:title" content="__OG_TITLE__" />
<meta property="og:description" content="__OG_DESCRIPTION__" />
<meta property="og:url" content="__OG_URL__" />

Code the handler

export const handler = async (event: any = {}): Promise<any> => {
    const { config, request } = event.Records[0].cf;

    console.log(`Event: ${JSON.stringify(event)}`)
    console.log('## Request URI: ', request.uri);
    console.log('## Event Type: ', config.eventType);

    switch (config.eventType) {
        case 'origin-request':
            return await processOriginRequest(request);
        case 'origin-response':
            return await processOriginResponse(event);
        default:
            return request;
    }
}

Process Origin Request

async function processOriginRequest(request: any = {}): Promise<any> {
    // Rewrite header
    let originalHost = request.headers.host[0].value;
    let bucketDomain = request.origin.custom.domainName;

    console.log('## Host: ', originalHost);
    if (!originalHost) {
        console.log('No Host');
        return request;
    }

    const appID = originalHost.split('.')[0];

    switch (request.uri) {
        case '/index.html':
            break;
        case '/favicon.ico':
            return getFavicon(appID);
        default:
            if (path.extname(request.uri) !== "") {
                request.headers['x-forwarded-host'] = [{ key: 'X-Forwarded-Host', value: originalHost }];
                request.headers.host = [{ key: 'Host', value: bucketDomain }];
                return request;
            }
    }

    try {
        const siteURL = `http://${bucketDomain}/index.html`;
        console.log('## SITE URL: ', siteURL);

        const indexResult = await axios.get(siteURL);
        let index = indexResult.data;

        const metas = await getMetadata(appID);

        for (const meta in metas) {
            if (metas.hasOwnProperty(meta)) {
                index = index.replace(new RegExp(`__${meta}__`, 'g'), metas[meta]);
            }
        }

        return {
            status: 200,
            statusDescription: 'HTTP OK',
            body: index
        }
    } catch (err) {
        console.log('### ERROR ###');
        console.log(JSON.stringify(err));
    }

    return request;
}

Process Origin Response

async function processOriginResponse(event: any = {}): Promise<any> {
    const { request, response } = event.Records[0].cf;
    // Do anything you need to do, if anything
    return response;
}

Deploy the solution!

Next is to deploy the solution - GitHub. This is a fully working CDK example, with the edge function deployed in us-east-1 and the rest deployed into us-east-2 to demonstrate cross region deployments.

  1. Deploy the Lambda@Edge stack - named EdgeLambdaFunctionStack
  2. Deploy the Cloudfront Distribution stack - named CloudfrontSsrStack

Live Version Available

  1. https://ssrdemo.advocacy.kennethwinner.com
  2. https://ssrdemo2.advocacy.kennethwinner.com
  3. https://other.advocacy.kennethwinner.com

Social Share Results

Default FB Example

Default FB Example

First FB Example

First FB Example

Second FB Example

Second FB Example

Slack Share Results

Default Slack Example

Default Slack Example

First Slack Example

First Slack Example

Second Slack Example

Second Slack Example

References