01/2024

Elm AWS CloudFront

Elm is a functional programming language that compiles to JavaScript. AWS CloudFront is a content delivery network that can run JavaScript functions at the edge of the network and customize the user request and response events.

In this note we will build Lambda@Edge functions in CloudFront using Elm.

elm-aws-cloudfront.jpg

Yeah, yeah, but your scientists were so preoccupied with whether or not they could that they didn't stop to think if they should.

-- Dr. Ian Malcolm, Jurassic Park

AWS CloudFront

AWS CloudFront is a content delivery network (CDN) that can route user requests to defined origins such as S3 Bucket or an HTTP server.

It speeds the distribution of our static or dynamic content like .html pages, .js files, images and other assets.

AWS Lambda@Edge

AWS Lambda@Edge are lambda functions that allows us to run computations and customize CloudFront request and response events. As examples, we can enrich the response headers, forward the request to an authenticated origin, redirect the response to a different location.

AWS Lambda@Edge supports both Node.js and Python.

Elm Headless Worker

In one of our previous notes "elm headless worker" we created a simple request/response HTTP endpoint using Elm with ports and a little JavaScript snippet to run on the Node.js platform.

Since Lambda@Edge is a simple handler with a CloudFront event object and a callback function, we can source all the logic into Elm via ports:

const app = Elm.MyModule.init();
exports.handler = (event, context, callback) => {
const caller = (output) => {
callback(null, output);
app.ports.outputEvent.unsubscribe(caller);
}
// listen to Elm output and pass it to callback
app.ports.outputEvent.subscribe(caller);
// pass CloudFront to Elm
app.ports.inputEvent.send(event);
}

In this snippet we're passing the entire event object into Elm and when we receive a response we trigger the callback with the output data, this way all the custom logic we can do in a Lambda@Edge can be done in just Elm.

Using elm-aws-cloudfront Package

Given the complexity of the event object passed into Elm and the output callback, we have to create a set of encoders, decoders and handlers for the different origins, for that we created the Elm package marcodaniels/elm-aws-cloudfront.

With this package we can set the response cache header with the following example:

main : Program () (Model ()) Msg
main =
cloudFront
( originResponse
(\{ response, request } _ ->
response
|> withHeader { key = "cache-control", value = "public, max-age=100" }
|> toResponse
)
)
( inputEvent, outputEvent )

Let's dive into more details on how this package is structured and can help us building Lambda@Edge functions.

CloudFront Module

The CloudFront module provides the "main" function of our application as cloudFront and its definition looks like:

cloudFront :
(flags -> Maybe InputOrigin -> OutputEvent)
-> ( (Decode.Value -> Msg) -> Sub Msg, Encode.Value -> Cmd Msg )
-> Program flags (Model flags) Msg

It requires a function to map the origin event into a result output (Lambda Module) and a tuple of input and output ports ( inputEvent, outputEvent ) with the correspondent types ((Decode.Value -> Msg) -> Sub Msg, Encode.Value -> Cmd Msg).

The origin mapping function can take in some initial flags, this can be used to pass some default values and/or environment variables.

In the end it just returns an Elm Worker.

Lambda Module

In the CloudFront Lambda module we can use the originRequest and originResponse functions to map and customize the origin event and toRequest and toResponse functions to transform the event into a result output.

originRequest
(\{ request } _ ->
{ request | uri = "new" }
|> toRequest
)

Headers Module

The CloudFront Header module exposes the functions withHeader and withHeaders to add or update HTTP headers on the origin event. In the case if the header already exists it will be the last entry value that "wins".

response |> withHeaders
[ { key = "x-frame-options", value = "DENY" }
, { key = "x-content-type-options", value = "nosniff" }
, { key = "x-xss-protection", value = "1; mode=block" }
]

Elm immutable values

In some Lambda@Edge example functions we can see objects like request, response and headers being directly accessed and modified into the callback function.

exports.handler = (event, context, callback) => {
const request = event.Records[0].cf.request;
const headerName = 'Auth-Header'
// direct update to "request.headers[...]"
request.headers[headerName.toLowerCase()] = ...
// direct update to "request.querystring"
request.querystring = ...;
callback(null, request);
};

This is not allowed in Elm, as all values in Elm are immutable we have to create a new return object - the functions withHeader and withHeaders handle that by default.

To create the same example as above with Elm:

originRequest
(\{ request } _ ->
-- returning the original "request" object
-- assigning a new value to the "querystring" property
{ request | querystring = Just "..." }
-- adding new header
|> withHeader {key = "auth-header", value = "..." }
|> toRequest
)

Building elm-aws-cloudfront applications

The package "elm-aws-cloudfront" requires the clients to setup and export a JavaScript handler together with the bundled Elm output, so it can be executed in the Node.js platform in Lambda@Edge.

To ease this process we've created a Nix derivation that can compile the Elm code, include the JavaScript handler and bundle its contents into one single .js file to be deployed into Lambda@Edge.

In the following example we are building and bundling two Lambda@Edge handlers:

cloudfront.buildElmAWSCloudFront {
src = ./.;
elmSrc = ./elm-srcs.nix;
elmRegistryDat = ./registry.dat;
lambdas = [
{ module = ./src/MyModuleOne.elm; }
{
module = ./src/MyModuleTwo.elm;
flags = [ ''token:"token-here"'' ''url:"url-here"'' ];
}
];
}

The Nix derivation also allows us to pass initial flags into the Elm module.

In order for the derivation to build, it requires elm-srcs.nix and registry.dat files to be generated with elm2nix and the Elm ports definition to be:

port inputEvent : (Decode.Value -> msg) -> Sub msg
port outputEvent : Encode.Value -> Cmd msg

All the updates to the Nix derivation are released with the nix-... prefix in the elm-aws-cloudfront GitHub repository.

You're still here?

Thank you so much for reading this!

Creating elm-aws-cloudfront v1 has been quite a journey of many doubts. As the first version it covers most use cases of a Lambda@Edge but, we will definitely keep working on other improvements.

This exact website we're looking at is using elm-aws-cloudfront to handle all the .html and .js files as well as all the images, you can see the implementation in GitHub.

Until then!