eliasbrange.dev
Build a Simple URL Shortener with CloudFront KeyValueStore

Build a Simple URL Shortener with CloudFront KeyValueStore

1. Introduction

AWS announced the general availability of CloudFront KeyValueStore a while back. This global key-value datastore greatly increases the capabilities of CloudFront Functions. Use cases include A/B testing, handling feature flags, and more. This blog post will teach you how to build a simple and fully serverless URL shortener using the new feature.

2. CloudFront Functions and KeyValueStore

CloudFront functions is a capability of CloudFront that lets you run custom code to customize how your content is served. Like Lambda@Edge, it allows you to run code at the edge, closer to your users. Functions offer a more lightweight option than Lambda@Edge, perfect for use cases like URL rewriting, redirecting, or header manipulation.

CloudFront functions can handle extreme traffic at low latency but has some drawbacks. For example, they disallow network calls and file system access. For a comparison between CloudFront functions and Lambda@Edge, refer to the official AWS documentation.

CloudFront functions do not support environment variables. Before, this meant there was no way to configure a function dynamically. For example, if you wanted to create a URL shortener with only CloudFront functions, you must redeploy the function to add a new mapping. This had multiple drawbacks:

With the introduction of CloudFront KeyValueStore, you can now separate your code and configuration. This datastore is replicated globally and can be accessed with low latency from any edge location. In the URL shortener example, you can now store the mappings in the KeyValueStore and update them without redeploying the function.

CloudFront functions can be added to a CloudFront distribution in two ways:

We will implement our URL shortener with a viewer request function and a dummy origin. Whenever a request is made to the distribution, the function will query the KeyValueStore for a match. If there is a match, it will redirect the user to the stored URL. If no match is found, it will redirect the user to a fallback URL.

3. Creating a simple URL shortener

Let’s get building. The code samples below include regular CDK constructs, but they are used in an SST context. You might have to adjust the code samples slightly if you are a CDK user. If you haven’t tried SST yet, I highly recommend you check it out. The Developer Experience is fantastic, and you can always fall back to CDK constructs if needed.

Note

Support for CloudFront KeyValueStore was added in CDK version 2.118.0. At the time of writing, SST is bundled with version 2.110.1. If you are using SST, you must install a newer version of CDK.

3.1 Adding a CloudFront KeyValueStore

First, we add a CloudFront KeyValueStore to our stack. This KeyValueStore will hold the mappings between short and long URLs.

stacks/UrlShortenerStack.ts
1
import * as cf from "aws-cdk-lib/aws-cloudfront";
2
3
...
4
5
const kvStore = new cf.KeyValueStore(stack, "KeyValueStore", {
6
keyValueStoreName: "UrlShortenerKVStore",
7
});

3.2 Adding a CloudFront Function

Next, we add a CloudFront Function. This function will be invoked whenever a request is made to our CloudFront distribution. For example, when a request is made to https://example.com/short-url, the function will query the KeyValueStore for short-url. If there is a match, the function will return a redirect to the corresponding URL. The function will redirect to a default page if there is no match.

The function code looks like this:

src/viewer-request.js
1
import cf from 'cloudfront';
2
const kvsId = 'KEY_VALUE_STORE_ID_PLACEHOLDER'; // We need to inject this value.
3
const keyvaluestore = cf.kvs(kvsId);
4
5
async function handler(event) {
6
// Default URL to redirect to if no match is found.
7
let target = 'https://www.eliasbrange.dev';
8
9
// No need to query the KV store for the root page.
10
if (event.request.uri !== '/') {
11
const key = event.request.uri.split('/')[1];
12
13
try {
14
target = await kvs.get(key);
15
// Log to CloudWatch to enable tracking.
16
console.log(`Match found: ${key} -> ${target}`);
17
} catch (err) {
18
console.log(`Error when fetching key ${key}: ${err}`);
19
}
20
}
21
22
// Return a redirect to the target URL.
23
const response = {
24
statusCode: 302,
25
statusDescription: 'Found',
26
headers: {
27
location: { value: target },
28
},
29
};
30
return response;
31
}

Note that CloudFront functions run on a special runtime. This runtime is very restrictive in what you can do. Most notably, you may not access the file system or perform network calls.

In the code above, we need a way to inject the ID of the KeyValueStore in place of KEY_VALUE_STORE_ID_PLACEHOLDER. CloudFront functions do not support environment variables, so we must hard-code the ID in the function code. We can use CDK to inline it for us when deploying.

In the stack code, add the following:

stacks/UrlShortenerStack.ts
1
// Read the function code from the file and inject the KeyValueStore ID
2
const functionCode = readFileSync('src/viewer-request.js', 'utf8').replace(
3
'KEY_VALUE_STORE_ID_PLACEHOLDER',
4
kvStore.keyValueStoreId,
5
);
6
7
const viewerRequestFn = new cf.Function(stack, 'ViewerRequestFn', {
8
code: cf.FunctionCode.fromInline(functionCode),
9
runtime: cf.FunctionRuntime.JS_2_0,
10
});

First, we read the function code from the file and replace the placeholder with the actual KeyValueStore ID. We then create the function and specify the JS_2_0 runtime, which is required to utilize the KeyValueStore.

Lastly, we must associate the KeyValueStore with the function. The construct does not yet support this, so we use an escape hatch to add a raw override.

stacks/UrlShortenerStack.ts
1
// Use escape hatch to associate KeyValueStore with the function
2
const cfnViewerRequestFn = viewerRequestFn.node.defaultChild as cf.CfnFunction;
3
cfnViewerRequestFn.addPropertyOverride(
4
'FunctionConfig.KeyValueStoreAssociations',
5
[{ KeyValueStoreARN: kvStore.keyValueStoreArn }],
6
);

3.3 Adding a CloudFront Distribution

Finally, add the CloudFront distribution and associate the function as a viewer request function.

stacks/UrlShortenerStack.ts
1
const distribution = new cf.Distribution(stack, 'Distribution', {
2
defaultBehavior: {
3
origin: new HttpOrigin('nothing.to.see.here'),
4
functionAssociations: [
5
{
6
function: viewerRequestFn,
7
eventType: cf.FunctionEventType.VIEWER_REQUEST,
8
},
9
],
10
},
11
});

You may enter any origin here, as it will never be used. The function will always return a redirect to either a found match or the default URL.

3.4 Full stack code

The stack now looks like this:

stacks/UrlShortenerStack.ts
1
import * as cf from 'aws-cdk-lib/aws-cloudfront';
2
import { HttpOrigin } from 'aws-cdk-lib/aws-cloudfront-origins';
3
import { StackContext } from 'sst/constructs';
4
import { readFileSync } from 'fs';
5
6
export function UrlShortenerStack({ stack }: StackContext) {
7
const kvStore = new cf.KeyValueStore(stack, 'KeyValueStore', {
8
keyValueStoreName: 'UrlShortenerKVStore',
9
});
10
11
// Read the function code from the file and inject the KeyValueStore ID
12
const functionCode = readFileSync('src/viewer-request.js', 'utf8').replace(
13
'KEY_VALUE_STORE_ID_PLACEHOLDER',
14
kvStore.keyValueStoreId,
15
);
16
17
const viewerRequestFn = new cf.Function(stack, 'ViewerRequestFn', {
18
code: cf.FunctionCode.fromInline(functionCode),
19
runtime: cf.FunctionRuntime.JS_2_0,
20
});
21
22
// Use escape hatch to associate KeyValueStore with the function
23
const cfnViewerRequestFn = viewerRequestFn.node
24
.defaultChild as cf.CfnFunction;
25
cfnViewerRequestFn.addPropertyOverride(
26
'FunctionConfig.KeyValueStoreAssociations',
27
[{ KeyValueStoreARN: kvStore.keyValueStoreArn }],
28
);
29
30
const distribution = new cf.Distribution(stack, 'Distribution', {
31
defaultBehavior: {
32
origin: new HttpOrigin('nothing.to.see.here'),
33
functionAssociations: [
34
{
35
function: viewerRequestFn,
36
eventType: cf.FunctionEventType.VIEWER_REQUEST,
37
},
38
],
39
},
40
});
41
42
stack.addOutputs({
43
DistributionURL: distribution.distributionDomainName,
44
});
45
}

Not much code for a fully functional URL shortener. Let’s deploy it and see it in action.

As usual, deploying CloudFront resources can take a while. This is where the KeyValueStore can help us. By storing the configuration (the URL mappings) in the KeyValueStore, we can update the mappings without having to redeploy the CloudFront function.

4. Showtime

After deploying the stack, take note of your auto-generated distribution URL. It should look something like this: https://abcdef123456.cloudfront.net.

Open a browser and navigate to the URL. Since you haven’t added any mappings, you should be redirected to the default URL you specified in the function code. In my case, it’s https://www.eliasbrange.dev.

Go to the CloudFront console and add some mappings. To find it, go to CloudFront -> Functions -> KeyValueStores.

Click on the KeyValueStore you created earlier, you should see the following screen:

An empty KeyValueStore.
An empty KeyValueStore.

Click edit in the Key value pairs section and add a few mappings. I’m going to add the following three:

Three URL mappings.
Three URL mappings.

With these in place, if I navigate to my CloudFront URL and append /momento I should be redirected to my blog post about testing EventBridge using Momento Topics.

URL shortener in action.
URL shortener in action.

It works! With a few lines of code and a couple of CloudFront resources, you now have a functioning URL shortener. While using the lengthy CloudFront URL doesn’t make sense for a shortener, you can add a custom domain to your distribution.

In my case, I got the domain elbr.dev to create nice-looking short URLs, such as:

5. Tracking stats with CloudWatch

The setup is quite basic and missing some important features. For one, tracking how often each short URL is used. You could then share a blog post with different short URLs on different social networks and measure which one gets the most traction.

As mentioned, CloudFront functions run on a special runtime without network access. This means you cannot increment a counter in a database or send an event directly from the function. Instead, we will take advantage of the logging capabilities of CloudFront functions.

One advantage CloudFront functions have over Lambda@Edge is that all logs are consolidated in a single region. In Lambda@Edge, logs are spread across all regions where the function is replicated. This makes it hard to query logs across all regions. CloudFront functions instead send all logs to a log group named /aws/cloudfront/function/<FunctionName> in the us-east-1 region, no matter which edge location ran the function.

After making a few requests to the distribution, the log group contains the following:

CloudFront function logs.
CloudFront function logs.

Using the parse and stats functions in Logs Insights, we can extract the short URL and count the number of times each has been requested during the selected time period:

1
fields @timestamp
2
| filter @message like /Match found/
3
| parse @message /Match found: (?<@slug>.*?) ->/
4
| stats count(*) as count by @slug
5
| sort count desc

Run the query and then select the Visualization tab to see the results:

Request count per short URL.
Request count per short URL.

It may not be the flashiest solution, but it gets the job done.

6. Conclusion

In this blog post, you have learned how to use CloudFront Functions and KeyValueStore to create a simple URL shortener. You have also seen how to track usage statistics using CloudWatch Logs Insights.

It certainly isn’t the most advanced URL shortener out there. But it is cheap and gets the job done, and being fully serverless means there’s little to maintain.


About the author

I'm Elias Brange, a Cloud Consultant and AWS Community Builder in the Serverless category. I'm on a mission to drive Serverless adoption and help others on their Serverless AWS journey.

Did you find this article helpful? Share it with your friends and colleagues using the buttons below. It could help them too!

Are you looking for more content like this? Follow me on LinkedIn & Twitter !