Simulating CloudFront Functions on localhost with Yulin
CloudFront Functions can be slightly inconvenient to test and iterate on, because the CFF JS2 syntax is idiosyncratic and does not support module exports. Often the only options are to use the CloudFront Function testing tool in the AWS console, or to deploy to a development account and iterate that way.
The AWS simulator library Yulin can simulate CloudFront distributions and CloudFront Functions on localhost and in isolated unit tests. This lets you test and iterate rapidly on CloudFront Functions integrated with the rest of your simulated AWS system.
You might have a CloudFront Function handler implemented in CFF JS2 like this:
/**
* @typedef {import("@kensio/yulin/cloudfront").CloudFrontFunction.Event} CloudFrontEvent
* @typedef {import("@kensio/yulin/cloudfront").CloudFrontFunction.Request} CloudFrontRequest
* @typedef {import("@kensio/yulin/cloudfront").CloudFrontFunction.Response} CloudFrontResponse
*/
/**
* Handles a CloudFront Functions viewer request event.
* @param {CloudFrontEvent} event - The CloudFront Functions event object.
* @returns {CloudFrontRequest|CloudFrontResponse} A CloudFront request object or response object.
*/
export function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.startsWith("/foobar/redirectme/")) {
return redirect(302, "https://example.test/?ref=redirected");
}
return request;
}
The JS-friendly type definitions provided by Yulin are optional. They just make it a bit easier to work with the CFF JS2 syntax in modern TypeScript projects.
You can also optionally add some JS2 config for ESLint from Yulin in your eslint.config.ts:
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
import { cloudFrontFunctionsJs2 } from "@kensio/yulin/eslint";
export default defineConfig(
...tseslint.configs.recommended,
...cloudFrontFunctionsJs2,
);
Note that the CloudFront Function handler implementation above uses the export keyword. This means
it is not directly deployable to CloudFront without a small modification. We can use a
small helper function
to read the JS2 file and strip out the export when deploying it in CDK:
import { readFileSync } from "node:fs";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
/**
* Get CloudFront Function code from a module file and replace the export of the
* handler function.
* This is to allow normal import of CFF JS2 functions in tests and simulations.
* This intentionally assumes a single export function handler(...) convention
* rather than doing full JavaScript parsing.
*/
export function cloudFrontFunctionCodeFromModuleFile(
filePath: string,
): cloudfront.FunctionCode {
// eslint-disable-next-line security/detect-non-literal-fs-filename
const source = readFileSync(filePath, "utf8");
const cloudFrontSource = source.replace(
/\bexport\s+function\s+handler\s*\(/,
"function handler(",
);
return cloudfront.FunctionCode.fromInline(cloudFrontSource);
}
We can then use the CFF JS2 handler function for real deployments with CDK, in tests and for local dev.
In a CDK stack that could look like this:
import * as cdk from "aws-cdk-lib/core";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import {
cloudFrontFunctionCodeFromModuleFile
} from "../src/util/cloudfront-function-cdk.js";
export class FoobarStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const redirectCloudFrontFunction = new cloudfront.Function(
this,
"RedirectCloudFrontFunction",
{
code: cloudFrontFunctionCodeFromModuleFile(
"../src/cff/redirect.cff.js",
),
},
);
// ...
}
}
We can set up simulation of the CloudFront Function for local dev like this with Yulin:
import { SimAws } from "@kensio/yulin";
import { serveSimAws } from "@kensio/yulin/serve";
import {
CreateBucketCommand,
PutBucketWebsiteCommand,
} from "@aws-sdk/client-s3";
import {
CreateDistributionCommand,
CreateFunctionCommand,
} from "@aws-sdk/client-cloudfront";
import { handler as cffHandler } from "../src/cff/redirect.cff.js";
import { makeCffFunctionCodeInput } from "@kensio/yulin/cloudfront";
const simAws = new SimAws();
const srv = await serveSimAws({ simAws });
const simS3 = simAws.region("eu-west-2").s3();
await simS3.createBucket(new CreateBucketCommand({ Bucket: "foo-bucket" }));
await simS3.putBucketWebsite(
new PutBucketWebsiteCommand({
Bucket: "foo-bucket",
WebsiteConfiguration: {
IndexDocument: {
Suffix: "index.html",
},
},
}),
);
simS3.mountBucketFilesystem("foo-bucket", repoPath("/path/to/static/website/public"));
const simCloudFront = simAws.cloudFront();
const createCffOut = await simCloudFront.createFunction(
new CreateFunctionCommand({
Name: "redirect-cff",
FunctionConfig: {
Comment: "Viewer Request CloudFront Function",
Runtime: "cloudfront-js-2.0",
},
FunctionCode: makeCffFunctionCodeInput(cffHandler),
}),
);
const createDistroOut = await simCloudFront.createDistribution(
new CreateDistributionCommand({
DistributionConfig: {
CallerReference: "default-behavior",
Comment: "Foobar CDN",
Enabled: true,
Origins: {
Quantity: 1,
Items: [
{
Id: "foobar-s3-origin",
DomainName: "foo-bucket.s3.amazonaws.com",
S3OriginConfig: { OriginAccessIdentity: "" },
},
],
},
DefaultCacheBehavior: {
TargetOriginId: "foobar-s3-origin",
ViewerProtocolPolicy: "allow-all",
FunctionAssociations: {
Quantity: 1,
Items: [
{
EventType: "viewer-request",
FunctionARN: createCffOut.FunctionMetadata.FunctionARN,
},
],
},
},
},
}),
);
const distroId = createDistroOut.Distribution?.Id;
const distroUrl = srv.localUrl(`http://${distroId}.cloudfront.net/`);
console.log(distroUrl.toString());
That creates a simulated S3 Bucket which serves files off a local filesystem directory. We then create a sim CloudFront Function and Distribution, and point those to the sim S3 Bucket.
After running that, you can visit the website on a URL like:
http://exq46tzc9stbl6.cloudfront.net.sim-aws.localhost:55027/
That serves the sim S3 Bucket through the sim CloudFront Distribution, applying the sim CloudFront Function as appropriate.
You can also use the same simulated AWS setup in isolated unit tests to test realistic system behaviours across the different AWS resources.