Skip to content

Simulated CloudFront

Yulin simulates CloudFront distributions, origins, cache behaviour and edge functions. Use simAws.cloudFront() as part of a simulated AWS environment or create a standalone SimCloudFront. serveSimAws exposes distributions over localhost.

Create an S3 bucket, then create a distribution whose origin uses the bucket’s REST endpoint.

/**
* Creating a simulated CloudFront Distribution with a simulated S3 Origin.
*/
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
import {
CreateBucketCommand,
PutBucketPolicyCommand,
PutPublicAccessBlockCommand,
} from "@aws-sdk/client-s3";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const simS3 = simAws.s3();
const simCloudFront = simAws.cloudFront();
await simS3.createBucket(
new CreateBucketCommand({
Bucket: "foo-bucket",
}),
);
// The Origin below has no origin access control, so it reads the Bucket
// anonymously and only a public read grant lets it serve anything.
await simS3.putPublicAccessBlock(
new PutPublicAccessBlockCommand({
Bucket: "foo-bucket",
PublicAccessBlockConfiguration: {
BlockPublicAcls: true,
IgnorePublicAcls: true,
},
}),
);
await simS3.putBucketPolicy(
new PutBucketPolicyCommand({
Bucket: "foo-bucket",
Policy: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: "*",
Action: "s3:GetObject",
Resource: "arn:aws:s3:::foo-bucket/*",
},
}),
}),
);
const distributionCreation = await simCloudFront.createDistribution(
new CreateDistributionCommand({
DistributionConfig: {
CallerReference: "assets-cdn",
Comment: "Assets CDN",
Enabled: true,
Origins: {
Quantity: 1,
Items: [
{
Id: "assets-origin",
DomainName: "foo-bucket.s3.amazonaws.com",
S3OriginConfig: {
OriginAccessIdentity: "",
},
},
],
},
DefaultCacheBehavior: {
TargetOriginId: "assets-origin",
ViewerProtocolPolicy: "allow-all",
},
},
}),
);
console.log(distributionCreation.Distribution?.DomainName);

An S3 Origin reads its bucket through GetObject. The bucket policy decides what the distribution can serve. Without an origin access control, CloudFront reads anonymously and the object must be publicly readable. A private bucket returns 403.

An Origin that does have an origin access control reads as the CloudFront service principal. The Bucket stays private and its policy names the Distribution. See Origin access controls for the Bucket policy that takes.

The example disables the block on public bucket policies, then grants s3:GetObject to Principal: "*". CDK’s publicReadAccess: true produces the same result.

A denied read reaches the viewer as a 403 from the Origin, and a Distribution’s custom error response for 403 replaces it. The usual single-page-app setup, rewriting 403 to /index.html, behaves here as it does in AWS.

S3OriginConfig.OriginAccessIdentity is refused. Leave it empty, as CloudFront itself writes it for an Origin that signs nothing.

Static sites, default root objects and error pages

Section titled “Static sites, default root objects and error pages”

DefaultRootObject maps the distribution root to an object such as index.html. CustomErrorResponses replaces selected Origin errors with another object and status code.

/**
* Serving a static site with a default root object and a custom error page.
*/
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
import {
CreateBucketCommand,
PutBucketPolicyCommand,
PutObjectCommand,
PutPublicAccessBlockCommand,
} from "@aws-sdk/client-s3";
import { SimAws } from "@kensio/yulin";
import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
const srv = await serveSimAws({ simAws });
try {
const simS3 = simAws.s3();
await simS3.createBucket(new CreateBucketCommand({ Bucket: "site-bucket" }));
// A CloudFront S3 Origin with no origin access control reads the Bucket
// anonymously, so what it serves has to be publicly readable.
await simS3.putPublicAccessBlock(
new PutPublicAccessBlockCommand({
Bucket: "site-bucket",
PublicAccessBlockConfiguration: {
BlockPublicAcls: true,
IgnorePublicAcls: true,
},
}),
);
await simS3.putBucketPolicy(
new PutBucketPolicyCommand({
Bucket: "site-bucket",
Policy: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: "*",
Action: "s3:GetObject",
Resource: "arn:aws:s3:::site-bucket/*",
},
}),
}),
);
const pages = {
"index.html": "<h1>Home</h1>",
"404.html": "<h1>Page not found</h1>",
};
for (const [key, body] of Object.entries(pages)) {
await simS3.putObject(
new PutObjectCommand({
Bucket: "site-bucket",
Key: key,
ContentType: "text/html",
Body: body,
}),
);
}
const distributionCreation = await simAws.cloudFront().createDistribution(
new CreateDistributionCommand({
DistributionConfig: {
CallerReference: "static-site",
Comment: "Static site",
Enabled: true,
DefaultRootObject: "index.html",
CustomErrorResponses: {
Quantity: 2,
Items: [
{
ErrorCode: 404,
ResponsePagePath: "/404.html",
ResponseCode: "404",
},
{
ErrorCode: 403,
ResponsePagePath: "/404.html",
ResponseCode: "404",
},
],
},
Origins: {
Quantity: 1,
Items: [
{
Id: "site-origin",
DomainName: "site-bucket.s3.amazonaws.com",
S3OriginConfig: { OriginAccessIdentity: "" },
},
],
},
DefaultCacheBehavior: {
TargetOriginId: "site-origin",
ViewerProtocolPolicy: "allow-all",
},
},
}),
);
const distroHostname = distributionCreation.Distribution!.DomainName!;
const home = await fetch(srv.localUrl(`http://${distroHostname}/`));
console.log(await home.text()); // <h1>Home</h1>
const missing = await fetch(srv.localUrl(`http://${distroHostname}/nowhere`));
console.log(missing.status); // 404
console.log(await missing.text()); // <h1>Page not found</h1>
} finally {
await srv.close();
}

The default root object applies only to /. A request for /blog/ reaches the Origin unchanged, even if the Origin contains /blog/index.html. Cache Behaviors and viewer-request functions see the substituted root path. The value may contain a path such as public/index.html, but it must not start with /. Invalid values raise InvalidDefaultRootObject.

A custom error response replaces the Origin’s response when its status matches ErrorCode. The codes CloudFront supports are 400, 403, 404, 405, 414, 416, 500, 501, 502, 503 and 504. The response page is fetched as a request in its own right, and the Cache Behavior matching ResponsePagePath chooses which Origin it comes from. Error pages can live somewhere other than the content that failed. ResponseCode is the status the viewer sees. That is how a single-page app serves its shell with a 200 for a URL the Bucket has no object for. It is one of the same error codes or 200, the set CloudFront allows. Where the response page is itself missing, the viewer gets the status from fetching it, as in CloudFront.

Behind an origin access control, a response page the Bucket does not hold answers 403 where a public Bucket answers 404. The Bucket policy an origin access control is written with grants s3:GetObject and no s3:ListBucket, and S3 refuses a key whose absence it may not report. A test whose Bucket was never loaded with the site’s error page sees that refusal in place of the page.

A viewer-response function never sees a custom error page. CloudFront runs no viewer-response function once the Origin has answered 400 or higher, and simulated CloudFront does the same, for a CloudFront Function and a Lambda@Edge function alike. The status the Origin returned is what decides that, whatever ResponseCode puts in its place.

ErrorCachingMinTTL says how many seconds the Distribution holds the error for before it reads the Origin again. A rule carrying it alone, with no ResponsePagePath, configures error caching for a status the Distribution serves no page for. See What a Distribution stores.

Use serveSimAws to send HTTP requests through the simulated distribution on localhost.

/**
* Serving a simulated CloudFront Distribution on localhost.
*/
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
import {
CreateBucketCommand,
PutBucketPolicyCommand,
PutObjectCommand,
PutPublicAccessBlockCommand,
} from "@aws-sdk/client-s3";
import { SimAws } from "@kensio/yulin";
import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
const srv = await serveSimAws({ simAws });
try {
const simS3 = simAws.s3();
const simCloudFront = simAws.cloudFront();
await simS3.createBucket(
new CreateBucketCommand({
Bucket: "foo-bucket",
}),
);
// A CloudFront S3 Origin with no origin access control reads the Bucket
// anonymously, so what it serves has to be publicly readable.
await simS3.putPublicAccessBlock(
new PutPublicAccessBlockCommand({
Bucket: "foo-bucket",
PublicAccessBlockConfiguration: {
BlockPublicAcls: true,
IgnorePublicAcls: true,
},
}),
);
await simS3.putBucketPolicy(
new PutBucketPolicyCommand({
Bucket: "foo-bucket",
Policy: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: "*",
Action: "s3:GetObject",
Resource: "arn:aws:s3:::foo-bucket/*",
},
}),
}),
);
await simS3.putObject(
new PutObjectCommand({
Bucket: "foo-bucket",
Key: "hello.txt",
Body: "Hello from simulated CloudFront",
}),
);
const distributionCreation = await simCloudFront.createDistribution(
new CreateDistributionCommand({
DistributionConfig: {
CallerReference: "localhost-assets-cdn",
Comment: "Localhost Assets CDN",
Enabled: true,
Origins: {
Quantity: 1,
Items: [
{
Id: "assets-origin",
DomainName: "foo-bucket.s3.amazonaws.com",
S3OriginConfig: {
OriginAccessIdentity: "",
},
},
],
},
DefaultCacheBehavior: {
TargetOriginId: "assets-origin",
ViewerProtocolPolicy: "allow-all",
},
},
}),
);
const distroHostname = distributionCreation.Distribution!.DomainName!;
const url = srv.localUrl(`http://${distroHostname}/hello.txt`);
const response = await fetch(url);
console.log(response.status);
console.log(await response.text());
} finally {
await srv.close();
}

The Distribution domain is adapted through server.localUrl(...) so that the request is sent to the local Yulin server while preserving the simulated CloudFront hostname.

A test that needs no browser can skip the port. SimAwsHttp answers the same requests in the process, with no server listening and no URL to adapt. An alternate domain name a simulated Route53 answers for is requested by its own name, and simAwsHttp.fetch("https://cdn.example.test/") reaches the Distribution behind it. See requests without a port.

An Origin with CustomOriginConfig routes requests to another simulated HTTP service. CloudFront resolves DomainName in the simulated environment and serves the request in process. A distribution can front a simulated HTTP API endpoint (<api-id>.execute-api.<region>.amazonaws.com), a simulated Lambda Function URL (<url-id>.lambda-url.<region>.on.aws), or anything a simulated Route53 record points at one of those.

The following distribution serves static files from S3 and sends /api/* to an HTTP API:

/**
* A simulated CloudFront Distribution fronting a simulated HTTP API.
*/
import {
CreateApiCommand,
CreateIntegrationCommand,
CreateRouteCommand,
CreateStageCommand,
} from "@aws-sdk/client-apigatewayv2";
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
import {
AddPermissionCommand,
CreateFunctionCommand,
} from "@aws-sdk/client-lambda";
import {
CreateBucketCommand,
PutBucketPolicyCommand,
PutObjectCommand,
PutPublicAccessBlockCommand,
} from "@aws-sdk/client-s3";
import { SimAws } from "@kensio/yulin";
import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
// A Bucket holding the site, readable by the Origin that reads it anonymously.
await simAws.s3().createBucket(new CreateBucketCommand({ Bucket: "site" }));
await simAws.s3().putObject(
new PutObjectCommand({
Bucket: "site",
Key: "index.html",
Body: "<h1>Site</h1>",
}),
);
await simAws.s3().putPublicAccessBlock(
new PutPublicAccessBlockCommand({
Bucket: "site",
PublicAccessBlockConfiguration: {
BlockPublicAcls: true,
IgnorePublicAcls: true,
},
}),
);
await simAws.s3().putBucketPolicy(
new PutBucketPolicyCommand({
Bucket: "site",
Policy: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: "*",
Action: "s3:GetObject",
Resource: "arn:aws:s3:::site/*",
},
}),
}),
);
// An HTTP API serving /api/things from a function.
const { FunctionArn } = await simAws.lambda().createFunction(
new CreateFunctionCommand({
FunctionName: "things",
Role: "arn:aws:iam::111111111111:role/ThingsRole",
Code: { ZipFile: makeLambdaZipFileInput(() => ({ things: ["kettle"] })) },
}),
);
const apiGateway = simAws.apiGatewayV2();
const { ApiId, ApiEndpoint } = await apiGateway.createApi(
new CreateApiCommand({ Name: "things", ProtocolType: "HTTP" }),
);
const { IntegrationId } = await apiGateway.createIntegration(
new CreateIntegrationCommand({
ApiId,
IntegrationType: "AWS_PROXY",
IntegrationUri: FunctionArn,
PayloadFormatVersion: "2.0",
}),
);
await apiGateway.createRoute(
new CreateRouteCommand({
ApiId,
RouteKey: "GET /api/things",
Target: `integrations/${IntegrationId}`,
}),
);
await apiGateway.createStage(
new CreateStageCommand({ ApiId, StageName: "$default", AutoDeploy: true }),
);
await simAws.lambda().addPermission(
new AddPermissionCommand({
FunctionName: "things",
StatementId: "api-gateway-invoke",
Action: "lambda:InvokeFunction",
Principal: "apigateway.amazonaws.com",
SourceArn: `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/*/*`,
}),
);
// One Distribution serving the site, with /api/* going to the API.
const distributionCreation = await simAws.cloudFront().createDistribution(
new CreateDistributionCommand({
DistributionConfig: {
CallerReference: "site-and-api",
Comment: "Site and API CDN",
Enabled: true,
Origins: {
Quantity: 2,
Items: [
{
Id: "site-origin",
DomainName: "site.s3.amazonaws.com",
S3OriginConfig: { OriginAccessIdentity: "" },
},
{
Id: "api-origin",
DomainName: new URL(ApiEndpoint).hostname,
CustomOriginConfig: {
HTTPPort: 80,
HTTPSPort: 443,
OriginProtocolPolicy: "https-only",
},
},
],
},
DefaultCacheBehavior: {
TargetOriginId: "site-origin",
ViewerProtocolPolicy: "allow-all",
},
CacheBehaviors: {
Quantity: 1,
Items: [
{
PathPattern: "/api/*",
TargetOriginId: "api-origin",
ViewerProtocolPolicy: "allow-all",
},
],
},
},
}),
);
const distroHostname = distributionCreation.Distribution!.DomainName!;
const srv = await serveSimAws({ simAws });
try {
const page = await fetch(srv.localUrl(`http://${distroHostname}/index.html`));
const things = await fetch(
srv.localUrl(`http://${distroHostname}/api/things`),
);
console.log(await page.text());
console.log(await things.text());
} finally {
await srv.close();
}

The Origin domain is resolved for each request. The distribution and Origin service may be created in either order.

OriginPath is prefixed to the request path, as it is for an S3 Origin. An Origin path of /v1 sends a request for /things on to /v1/things.

In-process routing has these limits:

  • A domain unknown to the simulation fails with an error naming the Origin and the domain. No real request is made to it, and external HTTP Origins are unsupported.
  • The settings inside CustomOriginConfig describe how CloudFront connects over the network. The protocol policy, ports, SSL protocols and timeouts are accepted and ignored.
  • The Origin is reached anonymously unless it has an origin access control, as CloudFront reaches an Origin it has nothing to sign for. A Function URL or an HTTP API route authorizing with AWS_IAM therefore refuses the request. Origin access controls covers the Function URL that admits the Distribution and nothing else.

CloudFront adds an Origin’s custom headers to every request sent to that Origin. A service can use a private header value to restrict access to CloudFront.

The CloudFront API and CloudFormation name the field differently, and both spellings are accepted here. The API has CustomHeaders inside an Origin, and AWS::CloudFront::Distribution has OriginCustomHeaders, as the two differ over the viewer certificate ARN.

/**
* An HTTP API answering only the requests that came through the Distribution.
*/
import {
CreateApiCommand,
CreateIntegrationCommand,
CreateRouteCommand,
CreateStageCommand,
} from "@aws-sdk/client-apigatewayv2";
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
import {
AddPermissionCommand,
CreateFunctionCommand,
} from "@aws-sdk/client-lambda";
import { SimAws } from "@kensio/yulin";
import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
const originSecret = "5d6e2b0c6f564c1e9d5b2f1a5b8c9d70";
// A function serving the API, which reads the secret off every request.
const { FunctionArn } = await simAws.lambda().createFunction(
new CreateFunctionCommand({
FunctionName: "profile",
Role: "arn:aws:iam::111111111111:role/ProfileRole",
Code: {
ZipFile: makeLambdaZipFileInput(
(event: { headers: Record<string, string> }) =>
event.headers["x-origin-secret"] === originSecret
? { name: "Ada" }
: { message: "Forbidden" },
),
},
}),
);
const apiGateway = simAws.apiGatewayV2();
const { ApiId, ApiEndpoint } = await apiGateway.createApi(
new CreateApiCommand({ Name: "profile", ProtocolType: "HTTP" }),
);
const { IntegrationId } = await apiGateway.createIntegration(
new CreateIntegrationCommand({
ApiId,
IntegrationType: "AWS_PROXY",
IntegrationUri: FunctionArn,
PayloadFormatVersion: "2.0",
}),
);
await apiGateway.createRoute(
new CreateRouteCommand({
ApiId,
RouteKey: "GET /user/profile",
Target: `integrations/${IntegrationId}`,
}),
);
await apiGateway.createStage(
new CreateStageCommand({ ApiId, StageName: "$default", AutoDeploy: true }),
);
await simAws.lambda().addPermission(
new AddPermissionCommand({
FunctionName: "profile",
StatementId: "api-gateway-invoke",
Action: "lambda:InvokeFunction",
Principal: "apigateway.amazonaws.com",
SourceArn: `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/*/*`,
}),
);
// A Distribution that sends the secret with every request to that Origin.
const distributionCreation = await simAws.cloudFront().createDistribution(
new CreateDistributionCommand({
DistributionConfig: {
CallerReference: "user-site",
Comment: "User API CDN",
Enabled: true,
Origins: {
Quantity: 1,
Items: [
{
Id: "api-origin",
DomainName: new URL(ApiEndpoint).hostname,
CustomOriginConfig: {
HTTPPort: 80,
HTTPSPort: 443,
OriginProtocolPolicy: "https-only",
},
CustomHeaders: {
Quantity: 1,
Items: [
{ HeaderName: "x-origin-secret", HeaderValue: originSecret },
],
},
},
],
},
DefaultCacheBehavior: {
TargetOriginId: "api-origin",
ViewerProtocolPolicy: "allow-all",
},
},
}),
);
const distroHostname = distributionCreation.Distribution!.DomainName!;
const srv = await serveSimAws({ simAws });
try {
const throughCdn = await fetch(
srv.localUrl(`http://${distroHostname}/user/profile`),
);
const direct = await fetch(srv.localUrl(`${ApiEndpoint}/user/profile`));
// {"name":"Ada"}
console.log(await throughCdn.text());
// {"message":"Forbidden"}
console.log(await direct.text());
} finally {
await srv.close();
}

Two rules follow CloudFront’s own:

  • A header the viewer already sent is overwritten with the Origin’s value, whatever case the viewer wrote it in. A viewer cannot reach the origin with a guessed secret by sending the header through the Distribution.
  • A header name CloudFront refuses to add fails the Distribution at create and fails the Stack at deploy, naming the header. The denied names run from Cache-Control to X-Real-Ip, along with anything beginning X-Amz- or X-Edge-.

An S3 Origin takes the headers and reaches nothing with them. Sim CloudFront reads a Bucket through GetObject and builds no HTTP request for a header to travel on, and real S3 ignores a header it has no use for.

Several Origins may use the same domain when their paths, headers, or connection settings differ. Each Behavior selects an Origin by ID.

Two Origins that match in every property but the Id are one Origin written twice. Copying a Behavior and its Origin together, then editing the path pattern, leaves exactly that behind. Sim CloudFront keys an Origin by Id, as CloudFront does, and serves both Behaviors alike. An account has been seen to serve them differently, refusing every request on the second Behavior at the Origin. Why it did that is unconfirmed.

The Distribution records each repeat as it is created or updated, and warns about it on the console. Each entry in redundantOrigins names the Origin, the earlier Origin it repeats and the domain both of them name. A test can assert the list is empty.

/**
* Catching an Origin a Distribution declares twice.
*/
import {
CreateDistributionCommand,
type Origin,
} from "@aws-sdk/client-cloudfront";
import { SimAws } from "@kensio/yulin";
const simCloudFront = new SimAws().cloudFront();
// The two Behaviors below were written by copying one of them, so the second
// Origin says everything the first one says.
const apiOrigin = (originId: string): Origin => ({
Id: originId,
DomainName: "api.example.test",
CustomOriginConfig: {
HTTPPort: 80,
HTTPSPort: 443,
OriginProtocolPolicy: "https-only",
},
CustomHeaders: {
Quantity: 1,
Items: [
{
HeaderName: "x-origin-secret",
HeaderValue: "5d6e2b0c6f564c1e9d5b2f1a5b8c9d70",
},
],
},
});
const creation = await simCloudFront.createDistribution(
new CreateDistributionCommand({
DistributionConfig: {
CallerReference: "user-site",
Comment: "User API CDN",
Enabled: true,
Origins: {
Quantity: 2,
Items: [apiOrigin("live-origin"), apiOrigin("preview-origin")],
},
DefaultCacheBehavior: {
TargetOriginId: "live-origin",
ViewerProtocolPolicy: "allow-all",
},
CacheBehaviors: {
Quantity: 1,
Items: [
{
PathPattern: "/preview/*",
TargetOriginId: "preview-origin",
ViewerProtocolPolicy: "allow-all",
},
],
},
},
}),
);
const distribution = simCloudFront.getSimDistributionById(
creation.Distribution!.Id!,
);
// [
// {
// originId: "preview-origin",
// repeatsOriginId: "live-origin",
// domainName: "api.example.test",
// },
// ]
console.log(distribution?.redundantOrigins);

Sameness is every property the config declares apart from the Id, and not only the properties the simulation reads. An Origin differing by a connection setting sim CloudFront ignores is left alone. A property written as an empty string counts as one left out, and custom headers count by name and value however they were ordered or cased.

A distribution with alternate domain names requires a valid ACM certificate. Yulin raises InvalidViewerCertificate when:

  • the certificate must be in us-east-1, wherever the rest of your infrastructure lives
  • the certificate must exist and be ISSUED
  • every alternate domain name must be covered by the certificate’s domain name or one of its subject alternative names, with a wildcard covering exactly one label

The us-east-1 rule is easy to miss, because nothing else in a stack cares about it. A Distribution in eu-west-2 with a certificate alongside it looks fine until CloudFront refuses it.

/**
* Catching an ACM certificate CloudFront will not accept.
*/
import { RequestCertificateCommand } from "@aws-sdk/client-acm";
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
// A certificate alongside the rest of the stack, rather than in us-east-1.
const requestOutput = await simAws
.region("eu-west-2")
.acm()
.requestCertificate(
new RequestCertificateCommand({ DomainName: "example.test" }),
);
await simAws.backgroundTasksComplete();
try {
await simAws.cloudFront().createDistribution(
new CreateDistributionCommand({
DistributionConfig: {
CallerReference: "site-distribution",
Comment: "Site distribution",
Enabled: true,
Aliases: { Quantity: 1, Items: ["example.test"] },
Origins: { Quantity: 0, Items: [] },
DefaultCacheBehavior: {
TargetOriginId: "origin",
ViewerProtocolPolicy: "redirect-to-https",
},
ViewerCertificate: {
ACMCertificateArn: requestOutput.CertificateArn,
SSLSupportMethod: "sni-only",
},
},
}),
);
} catch (error) {
// InvalidViewerCertificate: ... is in eu-west-2, but CloudFront only accepts
// ACM Certificates in us-east-1
console.log((error as Error).message);
}

The CloudFront API and CloudFormation capitalise this field differently, and sim CloudFront accepts both. SDK calls use ACMCertificateArn and SSLSupportMethod, as above. AWS::CloudFront::Distribution uses AcmCertificateArn and SslSupportMethod. A template or CDK app works without changes.

A Distribution using CloudFrontDefaultCertificate needs no ACM certificate, and it goes unchecked. A standalone new SimCloudFront() has no sim ACM to check against, and skips the check as well.

Disable a distribution with UpdateDistributionCommand before deleting it. Deleting an enabled distribution raises DistributionNotDisabled.

UpdateDistributionCommand takes a whole DistributionConfig, and applies the update as a replacement. Anything left out of the new config is dropped, including alternate domain names and the default root object. Read the Distribution first, change the field you want, and send the config back.

Once the Distribution is deleted, a request to its CloudFront domain or any of its alternate domain names stops resolving to it, and those alternate domain names are free for another Distribution.

/**
* Disabling a simulated CloudFront Distribution and then deleting it.
*/
import {
CreateDistributionCommand,
DeleteDistributionCommand,
type DistributionConfig,
GetDistributionCommand,
UpdateDistributionCommand,
} from "@aws-sdk/client-cloudfront";
import { CreateBucketCommand } from "@aws-sdk/client-s3";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const simCloudFront = simAws.cloudFront();
await simAws
.s3()
.createBucket(new CreateBucketCommand({ Bucket: "site-bucket" }));
const distributionConfig: DistributionConfig = {
CallerReference: "site-distribution",
Comment: "Site distribution",
Enabled: true,
Origins: {
Quantity: 1,
Items: [
{
Id: "site-origin",
DomainName: "site-bucket.s3.amazonaws.com",
S3OriginConfig: { OriginAccessIdentity: "" },
},
],
},
DefaultCacheBehavior: {
TargetOriginId: "site-origin",
ViewerProtocolPolicy: "allow-all",
},
};
const created = await simCloudFront.createDistribution(
new CreateDistributionCommand({ DistributionConfig: distributionConfig }),
);
await simAws.backgroundTasksComplete();
const distributionId = created.Distribution?.Id;
try {
await simCloudFront.deleteDistribution(
new DeleteDistributionCommand({ Id: distributionId }),
);
} catch (error) {
// DistributionNotDisabled: Sim CloudFront Distribution ... is enabled, so it
// cannot be deleted. Disable it with UpdateDistribution first.
console.log((error as Error).message);
}
// Disable the Distribution, then delete it.
await simCloudFront.updateDistribution(
new UpdateDistributionCommand({
Id: distributionId,
DistributionConfig: { ...distributionConfig, Enabled: false },
}),
);
await simAws.backgroundTasksComplete();
await simCloudFront.deleteDistribution(
new DeleteDistributionCommand({ Id: distributionId }),
);
try {
await simCloudFront.getDistribution(
new GetDistributionCommand({ Id: distributionId }),
);
} catch (error) {
// NoSuchDistribution: No sim CloudFront Distribution with ID ...
console.log((error as Error).message);
}

DeleteFunctionCommand removes a CloudFront Function by name, and answers NoSuchFunctionExists when the name matches nothing. A cache Behavior still pointing at a deleted Function runs no Function code.

Yulin supports viewer-request and viewer-response CloudFront Functions.

A viewer-response Function runs for an Origin status below 400. CloudFront skips the viewer-response event once the Origin has answered 400 or higher (see Limitations), and so does this simulation.

Use makeCffFunctionCodeInput to pass a JavaScript handler to CreateFunctionCommand. Use CloudFormation bindings for a function declared in a template. Handler references support breakpoints and local state. Use source code when the test covers the deployed function or its 10 KB size limit. A Lambda@Edge function is an ordinary simulated Lambda function, and the same choice covers it.

The host header a function sees is the hostname the request was made to CloudFront with, being the Distribution domain name or one of its alternate domain names. Requests served on localhost arrive with a Yulin-local host such as distro123.cloudfront.net.sim-aws.localhost:52341, and the local suffix and port are dropped before the function runs. A function building a URL from event.request.headers.host.value behaves as it would on AWS. As on AWS, host is read-only, and a host a function writes is discarded before the Origin sees it.

A header arriving more than once reaches the Function as one entry holding every value it arrived with. value carries the first, and multiValue carries all of them, the same shape a repeated query string parameter has. A response setting three cookies gives a viewer-response Function this:

event.response.headers["set-cookie"];
// {
// value: "session=abc123; Path=/",
// multiValue: [
// { value: "session=abc123; Path=/" },
// { value: "state=; Max-Age=0" },
// { value: "signed-in=1; Path=/" },
// ],
// }

A Function returning that response untouched leaves all three cookies on their way to the viewer. A Function writing multiValue sends one header per value in it, and CloudFront ignores value while both are there. Writing value on its own sends a single header.

A Function reads the query string as the viewer spelled it. ?q=%E5%AE%B6 arrives as event.request.querystring.q.value === "%E5%AE%B6", ?q=a+b keeps its plus, and a percent-encoded parameter name stays encoded. Whatever a Function leaves in querystring goes on to the Origin as it stands. A Function returning the request untouched forwards the query byte for byte, and one writing a value of its own encodes it (the same job it has on AWS).

/**
* Simulated CloudFront Functions.
*/
import {
CreateDistributionCommand,
CreateFunctionCommand,
} from "@aws-sdk/client-cloudfront";
import {
CreateBucketCommand,
PutBucketPolicyCommand,
PutPublicAccessBlockCommand,
} from "@aws-sdk/client-s3";
import { SimAws } from "@kensio/yulin";
import {
makeCffFunctionCodeInput,
type CloudFrontFunction,
} from "@kensio/yulin/cloudfront";
import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
const srv = await serveSimAws({ simAws });
try {
const simS3 = simAws.s3();
const simCloudFront = simAws.cloudFront();
await simS3.createBucket(
new CreateBucketCommand({
Bucket: "foo-bucket",
}),
);
// A CloudFront S3 Origin with no origin access control reads the Bucket
// anonymously, so what it serves has to be publicly readable.
await simS3.putPublicAccessBlock(
new PutPublicAccessBlockCommand({
Bucket: "foo-bucket",
PublicAccessBlockConfiguration: {
BlockPublicAcls: true,
IgnorePublicAcls: true,
},
}),
);
await simS3.putBucketPolicy(
new PutBucketPolicyCommand({
Bucket: "foo-bucket",
Policy: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: "*",
Action: "s3:GetObject",
Resource: "arn:aws:s3:::foo-bucket/*",
},
}),
}),
);
function viewerRequestFunction(
event: CloudFrontFunction.ViewerRequestEvent,
): CloudFrontFunction.Request | CloudFrontFunction.Response {
if (event.request.uri === "/old-page.html") {
return {
statusCode: 302,
statusDescription: "Found",
headers: {
location: {
value: "https://example.test/new-page.html",
},
},
};
}
return event.request;
}
const functionCreation = await simCloudFront.createFunction(
new CreateFunctionCommand({
Name: "redirect-old-page",
FunctionConfig: {
Comment: "Redirect old page",
Runtime: "cloudfront-js-2.0",
},
FunctionCode: makeCffFunctionCodeInput(viewerRequestFunction),
}),
);
const distributionCreation = await simCloudFront.createDistribution(
new CreateDistributionCommand({
DistributionConfig: {
CallerReference: "function-cdn",
Comment: "Function CDN",
Enabled: true,
Origins: {
Quantity: 1,
Items: [
{
Id: "assets-origin",
DomainName: "foo-bucket.s3.amazonaws.com",
S3OriginConfig: {
OriginAccessIdentity: "",
},
},
],
},
DefaultCacheBehavior: {
TargetOriginId: "assets-origin",
ViewerProtocolPolicy: "allow-all",
FunctionAssociations: {
Quantity: 1,
Items: [
{
EventType: "viewer-request",
FunctionARN: functionCreation.FunctionMetadata.FunctionARN,
},
],
},
},
},
}),
);
const distroHostname = distributionCreation.Distribution!.DomainName!;
const url = srv.localUrl(`http://${distroHostname}/old-page.html`);
const response = await fetch(url, { redirect: "manual" });
console.log(response.status);
console.log(response.headers.get("location"));
} finally {
await srv.close();
}

If your CloudFront Function code lives in a module that exports the handler, use cloudFrontFunctionSourceFromModule in your CDK Stack to load it as inline CloudFront Function code. This lets the same function file use an export like export function handler(...) while still being accepted by CloudFront Function inline code.

/**
* cloudFrontFunctionSourceFromModule util function
*/
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import { Stack } from "aws-cdk-lib";
import type { Construct } from "constructs";
import { cloudFrontFunctionSourceFromModule } from "@kensio/yulin/cloudfront";
/**
* Example CDK stack using cloudFrontFunctionSourceFromModule to extract source
* code for a CloudFront Function handler from a module that uses `export`.
*/
export class WebsiteStack extends Stack {
constructor(scope: Construct, id: string) {
super(scope, id);
new cloudfront.Function(this, "RewriteFunction", {
code: cloudfront.FunctionCode.fromInline(
cloudFrontFunctionSourceFromModule("src/cff/rewrite.cff.js"),
),
runtime: cloudfront.FunctionRuntime.JS_2_0,
});
}
}

The referenced CloudFront Function module can then keep an exported handler:

/**
* @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.endsWith("/")) {
request.uri += "index.html";
} else if (!uri.includes(".") && !uri.endsWith("/")) {
request.uri += "/index.html";
}
return request;
}

CloudFront Functions run JS2, ECMAScript 5.1 plus a named subset of ES 6 to 12. It refuses constructs ordinary JavaScript allows. Yulin publishes ESLint and Oxlint configs that report those refusals in the editor, ahead of publication. See Linting CloudFront Functions JS2.

CloudFront also caps Function code at 10 KB, counted on the source as uploaded, comments and all. Simulated CreateFunction refuses anything larger with FunctionSizeLimitExceeded, as the real service does. A test that deploys the Stack reports the overrun where the rest of the suite runs, ahead of cdk deploy. A handler passed as a function reference carries no source to count, and the limit leaves it alone.

ListFunctions reports the Functions the Account holds. Each carries the FunctionConfig it was created with and the FunctionMetadata CloudFront gave it. DescribeFunction reports one by name, and GetFunction reports its code. A test that wants to know which Function a stack deployed, and on which runtime, asks one of these.

/**
* Reading a deployed CloudFront Function back.
*/
import {
CreateFunctionCommand,
DescribeFunctionCommand,
GetFunctionCommand,
ListFunctionsCommand,
} from "@aws-sdk/client-cloudfront";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const simCloudFront = simAws.cloudFront();
await simCloudFront.createFunction(
new CreateFunctionCommand({
Name: "beacon",
FunctionConfig: {
Comment: "Answers the analytics beacon",
Runtime: "cloudfront-js-2.0",
},
FunctionCode: Buffer.from(`
function handler(event) {
return { statusCode: 204, statusDescription: "No Content" };
}
`),
}),
);
// CloudFront publishes a new Function in the background.
await simAws.backgroundTasksComplete();
const listed = await simCloudFront.listFunctions(new ListFunctionsCommand({}));
// cloudfront-js-2.0
console.log(listed.FunctionList.Items[0]?.FunctionConfig.Runtime);
const described = await simCloudFront.describeFunction(
new DescribeFunctionCommand({ Name: "beacon", Stage: "LIVE" }),
);
// Answers the analytics beacon
console.log(described.FunctionSummary.FunctionConfig.Comment);
const got = await simCloudFront.getFunction(
new GetFunctionCommand({ Name: "beacon" }),
);
// The source the Function was created with.
console.log(Buffer.from(got.FunctionCode).toString());

Stage picks the copy to read. A Function is created into DEVELOPMENT and reaches LIVE once CloudFront has published it, and an omitted Stage means DEVELOPMENT, as it does on AWS. Asking for the LIVE copy of a Function still waiting to publish fails with NoSuchFunctionExists, and so does a name the Account holds no Function under.

CreatedTime and LastModifiedTime come off the simulated clock. Freezing time before the deploy pins both to an instant the test picked (see Controlling simulated time).

A Function backed by a handler function reference was given no source to keep. GetFunction answers with that handler’s own source text. That is the code the Function runs.

The whole list comes back. Marker and MaxItems paging is left out, matching the paging left out of the other simulated listings. UpdateFunction, PublishFunction and TestFunction are not simulated.

Calling a Function handler without a Distribution

Section titled “Calling a Function handler without a Distribution”

A test of the handler on its own, with no Distribution in front of it, still has to pass it a whole event. cloudFrontViewerRequestEventFactory and cloudFrontViewerResponseEventFactory make the two, so such a test says what the request or the response was and leaves the rest alone:

/**
* Making a CloudFront Functions event to call a handler with.
*/
import { VariantFactory } from "@kensio/part-factory";
import {
cloudFrontViewerResponseEventFactory,
type CloudFrontFunction,
} from "@kensio/yulin/cloudfront";
function securityHeadersHandler(
event: CloudFrontFunction.ViewerResponseEvent,
): CloudFrontFunction.Response {
const response = event.response;
const contentType = response.headers["content-type"]?.value ?? "";
if (contentType.startsWith("text/html")) {
response.headers["x-frame-options"] = { value: "DENY" };
}
return response;
}
// A response carrying a page. Those are the ones the policy is about.
const documentResponseFactory = new VariantFactory(
cloudFrontViewerResponseEventFactory,
{
response: {
headers: { "content-type": { value: "text/html; charset=utf-8" } },
},
},
);
const page = securityHeadersHandler(documentResponseFactory.make());
// DENY
console.log(page.headers["x-frame-options"]?.value);
// One response, for a test about a single asset. Everything else about it, down
// to the request that asked for it, is filled in as a served response's is.
const asset = securityHeadersHandler(
cloudFrontViewerResponseEventFactory.make({
response: { headers: { "content-type": { value: "text/css" } } },
}),
);
// undefined
console.log(asset.headers["x-frame-options"]?.value);

The defaults describe a request for /cloudfront/ reaching the Distribution, with a host of yulin.test, a session cookie and a viewer address. A viewer-response event carries the request that asked for it as well as the response, and the response’s own defaults are a status code and no headers.

The event factories page covers what the factories have in common.

A cache Behavior can run a Lambda function at any of CloudFront’s four events through LambdaFunctionAssociations. Those are viewer-request and viewer-response at the edge, and origin-request and origin-response either side of the Origin fetch. Where a CloudFront Function is a small piece of JavaScript running in CloudFront’s own runtime, a Lambda@Edge function is an ordinary simulated Lambda function, with an execution role, an environment and whatever SDK calls its handler makes.

Three things about Lambda@Edge catch people out on AWS, and simulated CloudFront refuses all three the way AWS refuses them, when the Distribution is written rather than when a request arrives.

  • The function lives in us-east-1, wherever the rest of the stack lives.
  • The association names a published version, such as :1. An unqualified ARN, $LATEST and an alias are each refused.
  • The execution role trusts edgelambda.amazonaws.com as well as lambda.amazonaws.com. A role set up for an ordinary function is the usual reason a first Lambda@Edge deploy fails.
/**
* A Lambda@Edge function rewriting a request at the viewer.
*/
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
import { CreateRoleCommand } from "@aws-sdk/client-iam";
import {
CreateFunctionCommand,
PublishVersionCommand,
} from "@aws-sdk/client-lambda";
import { SimAws } from "@kensio/yulin";
import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
import type { LambdaAtEdge } from "@kensio/yulin/cloudfront";
const simAws = new SimAws();
// A Lambda@Edge execution role trusts both service principals.
const role = await simAws.iam().createRole(
new CreateRoleCommand({
RoleName: "EdgeRewriteRole",
AssumeRolePolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: {
Service: ["lambda.amazonaws.com", "edgelambda.amazonaws.com"],
},
Action: "sts:AssumeRole",
},
}),
}),
);
// The function has to be in us-east-1, and the Behavior names a version.
const edgeLambda = simAws.region("us-east-1").lambda();
await edgeLambda.createFunction(
new CreateFunctionCommand({
FunctionName: "rewrite-uri",
Role: role.Role.Arn,
Code: {
ZipFile: makeLambdaZipFileInput((event: LambdaAtEdge.RequestEvent) => {
const { request } = event.Records[0].cf;
// A header is a list keyed by its lowercase name, and a status is a
// string. Both differ from the CloudFront Functions shapes.
if (request.headers["x-preview"]?.[0]?.value === "1") {
return {
status: "302",
headers: {
location: [{ key: "Location", value: "/preview.html" }],
},
};
}
request.uri = "/index.html";
return request;
}),
},
}),
);
const version = await edgeLambda.publishVersion(
new PublishVersionCommand({ FunctionName: "rewrite-uri" }),
);
await simAws.cloudFront().createDistribution(
new CreateDistributionCommand({
DistributionConfig: {
CallerReference: "edge-rewrite",
Comment: "Rewriting at the viewer",
Enabled: true,
Origins: {
Quantity: 1,
Items: [
{
Id: "site-origin",
DomainName: "edge-site.s3.amazonaws.com",
S3OriginConfig: { OriginAccessIdentity: "" },
},
],
},
DefaultCacheBehavior: {
TargetOriginId: "site-origin",
ViewerProtocolPolicy: "allow-all",
LambdaFunctionAssociations: {
Quantity: 1,
Items: [
{
EventType: "viewer-request",
LambdaFunctionARN: version.FunctionArn,
IncludeBody: false,
},
],
},
},
},
}),
);

A viewer-request handler returning the request carries on to the Origin with whatever it changed. Returning a response answers the viewer there and then, and the Origin is never read. A viewer-response handler returns the response the viewer gets.

Set IncludeBody to give a viewer-request or origin-request handler the request body, which arrives base64 encoded under request.body.data. A handler setting request.body.action to replace sends its own body to the Origin. The field belongs to the two request events, and an association setting it on viewer-response or origin-response is refused, as CloudFront refuses one.

A handler that throws answers the viewer with a 502, as CloudFront answers a failed edge function. The error reaches the function’s own output and nothing else.

An origin-request function runs after the Behavior has resolved the Origin and before the fetch. Its event carries request.origin, holding the Origin the fetch is about to read, under custom or s3 for the kind it is. A handler rewriting origin.custom.domainName sends the fetch to another Origin, and one rewriting path reads under another prefix. A header added to customHeaders reaches the Origin and the viewer never sees it. A handler returning a response answers the viewer with the Origin unread.

An origin-response function runs after the fetch and before the custom error page replaces an error status. It runs on whatever the Origin answered, including a 400 and above. That is where the origin events differ from the viewer events, and CloudFront documents it. Returning a response replaces what the viewer gets.

Both origin events run on a cache miss alone. A hit answers the viewer without either of them, as does a request a web ACL blocked or a viewer-request function answered. An origin-request function returning a response leaves the Origin unread, with no origin-response event after it.

At both origin events the host header holds the Origin’s own domain name. A viewer event shows the domain the viewer used.

/**
* A Lambda@Edge function choosing the Origin, and another one stamping what
* that Origin answered.
*/
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
import { CreateRoleCommand } from "@aws-sdk/client-iam";
import {
CreateFunctionCommand,
PublishVersionCommand,
} from "@aws-sdk/client-lambda";
import { SimAws } from "@kensio/yulin";
import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
import type { LambdaAtEdge } from "@kensio/yulin/cloudfront";
const simAws = new SimAws();
// A Lambda@Edge execution role trusts both service principals, at the origin
// events as at the viewer events.
const role = await simAws.iam().createRole(
new CreateRoleCommand({
RoleName: "EdgeOriginRole",
AssumeRolePolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: {
Service: ["lambda.amazonaws.com", "edgelambda.amazonaws.com"],
},
Action: "sts:AssumeRole",
},
}),
}),
);
const edgeLambda = simAws.region("us-east-1").lambda();
await edgeLambda.createFunction(
new CreateFunctionCommand({
FunctionName: "route-origin",
Role: role.Role.Arn,
Code: {
ZipFile: makeLambdaZipFileInput(
(event: LambdaAtEdge.OriginRequestEvent) => {
const { request } = event.Records[0].cf;
const { custom } = request.origin;
if (custom === undefined) {
return request;
}
// Everything under /api is served by the second Origin.
if (request.uri.startsWith("/api/")) {
custom.domainName = "orders.example.test";
}
// A header the viewer never sent and never sees.
custom.customHeaders["x-from-cloudfront"] = [
{ key: "X-From-CloudFront", value: "yes" },
];
return request;
},
),
},
}),
);
const routeVersion = await edgeLambda.publishVersion(
new PublishVersionCommand({ FunctionName: "route-origin" }),
);
await edgeLambda.createFunction(
new CreateFunctionCommand({
FunctionName: "stamp-origin-response",
Role: role.Role.Arn,
Code: {
ZipFile: makeLambdaZipFileInput(
(event: LambdaAtEdge.OriginResponseEvent): LambdaAtEdge.Response => {
const { response } = event.Records[0].cf;
// This runs for an Origin error too, so the status is worth keeping.
return {
...response,
headers: {
...response.headers,
"x-origin-status": [
{ key: "X-Origin-Status", value: response.status },
],
},
};
},
),
},
}),
);
const stampVersion = await edgeLambda.publishVersion(
new PublishVersionCommand({ FunctionName: "stamp-origin-response" }),
);
const customOriginConfig = {
HTTPPort: 80,
HTTPSPort: 443,
OriginProtocolPolicy: "https-only",
} as const;
await simAws.cloudFront().createDistribution(
new CreateDistributionCommand({
DistributionConfig: {
CallerReference: "edge-origin-routing",
Comment: "Choosing the Origin at the edge",
Enabled: true,
Origins: {
Quantity: 2,
Items: [
{
Id: "site-origin",
DomainName: "site.example.test",
CustomOriginConfig: customOriginConfig,
},
{
Id: "orders-origin",
DomainName: "orders.example.test",
CustomOriginConfig: customOriginConfig,
},
],
},
DefaultCacheBehavior: {
TargetOriginId: "site-origin",
ViewerProtocolPolicy: "allow-all",
LambdaFunctionAssociations: {
Quantity: 2,
Items: [
{
EventType: "origin-request",
LambdaFunctionARN: routeVersion.FunctionArn,
},
{
EventType: "origin-response",
LambdaFunctionARN: stampVersion.FunctionArn,
},
],
},
},
},
}),
);

Two Origin rewrites are refused, and the viewer gets the 502 a failed edge function gets, carrying the reason (see Limitations). One switches an Origin between custom and s3. The other moves an S3 Origin to another Bucket.

CloudFront takes one edge function per event type, and it does not combine CloudFront Functions with Lambda@Edge at the viewer events. A Behavior with a viewer-request CloudFront Function and a viewer-response Lambda@Edge function is refused, and so is a Behavior naming both at one event type. Simulated CloudFront refuses the same combinations. The rule stops at the viewer. A viewer-request CloudFront Function runs alongside a Lambda@Edge function on either origin event.

Neither kind runs at the viewer response once the Origin has answered 400 or higher. CloudFront skips that event for an Origin error, and the status the Origin returned is what decides it (see Limitations).

Both kinds of function see the host header as the hostname the viewer reached CloudFront with, rather than the Yulin-local host a request served on localhost arrives with. As on AWS, host is read-only at the viewer request, and a host a handler writes is discarded before the Origin sees it.

AWS::CloudFront::Distribution takes LambdaFunctionAssociations on DefaultCacheBehavior and on any entry of CacheBehaviors. CloudFormation writes the list as a plain array where the SDK writes the Quantity and Items pair. Ref on an AWS::Lambda::Version answers the qualified function ARN. That is the value an association names, and the two fit together directly:

EdgeVersion:
Type: AWS::Lambda::Version
Properties:
FunctionName: !Ref RewriteFunction
SiteDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
DefaultCacheBehavior:
TargetOriginId: SiteOrigin
ViewerProtocolPolicy: allow-all
LambdaFunctionAssociations:
- EventType: viewer-request
LambdaFunctionARN: !Ref EdgeVersion

The function still has to live in us-east-1. A stack holding one is a us-east-1 stack.

An association naming a function version this simulation does not hold is left out of the deployed Distribution and recorded on stack.ignoredProperties, under the event type it was on. A template pointing at a function in a real account is the usual reason. The rest of the Behavior deploys, and the event the skipped association was on is left empty. A test that cares reads the record.

Everything real CloudFront refuses still fails the deployment. A function outside us-east-1, an ARN without a version qualifier, an execution role missing the edgelambda.amazonaws.com trust, an EventType that is none of CloudFront’s four, two functions on one event type and a viewer event running both kinds of edge function each fail a real deploy of the same template.

CDK reaches a Behavior through edgeLambdas, given a lambda.Version from the same stack:

/**
* A CDK Distribution running a Lambda@Edge function at the viewer request.
*/
import { Stack } from "aws-cdk-lib";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as iam from "aws-cdk-lib/aws-iam";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as s3 from "aws-cdk-lib/aws-s3";
import type { Construct } from "constructs";
/**
* Example CDK stack whose Distribution rewrites every request at the edge.
*
* The stack is in us-east-1, the one Region CloudFront runs a Lambda@Edge
* function from.
*/
export class SiteStack extends Stack {
constructor(scope: Construct, id: string) {
super(scope, id, { env: { region: "us-east-1" } });
const siteBucket = new s3.Bucket(this, "SiteBucket");
// A Lambda@Edge execution role trusts both service principals.
const edgeRole = new iam.Role(this, "EdgeRole", {
assumedBy: new iam.CompositePrincipal(
new iam.ServicePrincipal("lambda.amazonaws.com"),
new iam.ServicePrincipal("edgelambda.amazonaws.com"),
),
});
const rewriteFunction = new lambda.Function(this, "RewriteFunction", {
runtime: lambda.Runtime.NODEJS_22_X,
handler: "index.handler",
role: edgeRole,
code: lambda.Code.fromInline(`
exports.handler = async (event) => {
const { request } = event.Records[0].cf;
request.uri = "/index.html";
return request;
};
`),
});
new cloudfront.Distribution(this, "SiteDistribution", {
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(siteBucket),
edgeLambdas: [
{
// edgeLambdas takes a published version, and currentVersion
// is one.
functionVersion: rewriteFunction.currentVersion,
eventType: cloudfront.LambdaEdgeEventType.VIEWER_REQUEST,
},
],
},
});
}
}

cloudfront.experimental.EdgeFunction deploys from a us-east-1 stack, where the construct creates the function alongside everything else.

From a stack in any other Region the construct writes the function, its published version and an SSM parameter holding the version ARN into a support stack in us-east-1. The stack using the function reads that parameter back through a Custom::CrossRegionStringParameterReader resource, and the Behavior’s LambdaFunctionARN is an Fn::GetAtt on it. Simulated CloudFormation makes that read itself, against the Region the resource names, and the association ends up holding the ARN the support stack published. Deploy the whole cloud assembly to deploy both stacks:

await simAws.cloudFormation().deployCdkOut("cdk.out");

Deploy the using stack’s template on its own and no parameter has been written. The read finds nothing and says so on stack.ignoredProperties, the Behavior deploys without the association, and the site serves from the Origin (see Limitations).

A Distribution can put a WAFv2 web ACL in front of everything it serves. Name the web ACL’s ARN in WebACLId and the Distribution evaluates it against every request that arrives. A request the web ACL blocks gets 403 from the edge. A request it allows carries on to the cache Behavior and the Origin.

CloudFront takes its web ACL this way. WAFv2’s AssociateWebACL covers the regional resource types.

The web ACL has to be a CLOUDFRONT scope one, created in us-east-1 (see scopes). A WebACLId naming a REGIONAL web ACL, or one this simulation never created, is refused with InvalidWebACLId at CreateDistribution and at UpdateDistribution.

The web ACL decides before any other stage sees the request. A blocked request never reaches a viewer-request CloudFront Function, a cache Behavior, a response headers policy or the Origin.

A CloudFormation Distribution naming a web ACL this simulation does not hold deploys without one. The WebACLId lands on stack.ignoredProperties and every request is served, including the ones the web ACL would have decided. A template naming a web ACL from a real account is ordinary, and a site that failed to deploy over its firewall would cost a local dev server and a test suite every request they make. CreateDistribution still refuses the same WebACLId, as real CloudFront refuses it.

/**
* Blocking a request to a Distribution with a web ACL.
*/
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
import {
CreateBucketCommand,
PutBucketPolicyCommand,
PutObjectCommand,
} from "@aws-sdk/client-s3";
import { CreateWebACLCommand } from "@aws-sdk/client-wafv2";
import { SimAws } from "@kensio/yulin";
import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
const srv = await serveSimAws({ simAws });
try {
const simS3 = simAws.s3();
await simS3.createBucket(new CreateBucketCommand({ Bucket: "site-bucket" }));
await simS3.putBucketPolicy(
new PutBucketPolicyCommand({
Bucket: "site-bucket",
Policy: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: "*",
Action: "s3:GetObject",
Resource: "arn:aws:s3:::site-bucket/*",
},
}),
}),
);
await simS3.putObject(
new PutObjectCommand({
Bucket: "site-bucket",
Key: "admin/users.html",
ContentType: "text/html",
Body: "<h1>Users</h1>",
}),
);
// A CLOUDFRONT scope web ACL lives in us-east-1, wherever the Distribution
// was created from.
const acl = await simAws
.accountRegionScope(simAws.defaultAccountId, "us-east-1")
.wafV2()
.createWebAcl(
new CreateWebACLCommand({
Name: "site-acl",
Scope: "CLOUDFRONT",
DefaultAction: { Allow: {} },
VisibilityConfig: {
SampledRequestsEnabled: false,
CloudWatchMetricsEnabled: false,
MetricName: "site",
},
Rules: [
{
Name: "block-admin",
Priority: 0,
Action: { Block: {} },
Statement: {
ByteMatchStatement: {
FieldToMatch: { UriPath: {} },
PositionalConstraint: "STARTS_WITH",
SearchString: Buffer.from("/admin"),
TextTransformations: [{ Priority: 0, Type: "LOWERCASE" }],
},
},
VisibilityConfig: {
SampledRequestsEnabled: false,
CloudWatchMetricsEnabled: false,
MetricName: "block-admin",
},
},
],
}),
);
const creation = await simAws.cloudFront().createDistribution(
new CreateDistributionCommand({
DistributionConfig: {
CallerReference: "guarded-site",
Comment: "Site behind a web ACL",
Enabled: true,
WebACLId: acl.Summary!.ARN,
Origins: {
Quantity: 1,
Items: [
{
Id: "site-origin",
DomainName: "site-bucket.s3.amazonaws.com",
S3OriginConfig: { OriginAccessIdentity: "" },
},
],
},
DefaultCacheBehavior: {
TargetOriginId: "site-origin",
ViewerProtocolPolicy: "allow-all",
},
},
}),
);
const distroHostname = creation.Distribution!.DomainName!;
const blocked = await fetch(
srv.localUrl(`http://${distroHostname}/admin/users.html`),
);
console.log(blocked.status); // 403
// The Bucket still holds the page. The request never got as far as the
// Origin to ask for it.
} finally {
await srv.close();
}

See simulated WAFv2 for what a rule can inspect and how a blocked request is answered.

A response headers policy sets headers on everything a cache Behavior serves. Declare one as AWS::CloudFront::ResponseHeadersPolicy and reference it from the Behavior’s ResponseHeadersPolicyId. CDK’s ResponseHeadersPolicy construct synthesizes this shape.

/**
* Setting response headers on what a cache Behavior serves.
*/
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { SimAws } from "@kensio/yulin";
import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
const srv = await serveSimAws({ simAws });
try {
const stack = await simAws.cloudFormation().deployTemplate({
stackName: "site-stack",
template: {
Resources: {
SiteBucket: {
Type: "AWS::S3::Bucket",
Properties: {
BucketName: "site-bucket",
PublicAccessBlockConfiguration: {
BlockPublicAcls: true,
IgnorePublicAcls: true,
},
},
},
// The Origin reads the Bucket anonymously, so the site needs a policy
// making it publicly readable.
SiteBucketPolicy: {
Type: "AWS::S3::BucketPolicy",
DependsOn: "SiteBucket",
Properties: {
Bucket: "site-bucket",
PolicyDocument: {
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: "*",
Action: "s3:GetObject",
Resource: "arn:aws:s3:::site-bucket/*",
},
},
},
},
CacheHeaders: {
Type: "AWS::CloudFront::ResponseHeadersPolicy",
Properties: {
ResponseHeadersPolicyConfig: {
Name: "CacheHeaders",
CustomHeadersConfig: {
Items: [
{
Header: "Cache-Control",
Override: true,
Value: "public, max-age=0, must-revalidate",
},
],
},
},
},
},
SiteDistribution: {
Type: "AWS::CloudFront::Distribution",
DependsOn: ["SiteBucket", "CacheHeaders"],
Properties: {
DistributionConfig: {
DefaultRootObject: "index.html",
Origins: [
{
Id: "SiteOrigin",
DomainName: "site-bucket.s3.amazonaws.com",
S3OriginConfig: {},
},
],
DefaultCacheBehavior: {
TargetOriginId: "SiteOrigin",
ViewerProtocolPolicy: "allow-all",
ResponseHeadersPolicyId: { Ref: "CacheHeaders" },
},
},
},
},
},
Outputs: {
DistributionDomainName: {
Value: { "Fn::GetAtt": ["SiteDistribution", "DomainName"] },
},
},
},
});
await stack.waitForDeployComplete();
await simAws.s3().putObject(
new PutObjectCommand({
Bucket: "site-bucket",
Key: "index.html",
ContentType: "text/html",
Body: "<h1>Home</h1>",
}),
);
const domainName = stack.output("DistributionDomainName");
const response = await fetch(srv.localUrl(`http://${domainName}/`));
console.log(response.headers.get("cache-control"));
} finally {
await srv.close();
}

Each header in CustomHeadersConfig carries an Override boolean. With it set, the policy’s value replaces one the Origin sent. Without it, the Origin’s value is kept and the policy’s is dropped. A header the Origin left out is added either way.

RemoveHeadersConfig takes headers away, and is applied before the added ones. A header named in both sections ends up present with the policy’s value.

The policy is applied after a custom error response is fetched and before the viewer-response event, as CloudFront does. An error page carries the policy’s headers. A viewer-response Function sees them in event.response.headers and can change them, where the Origin answered below 400 and the Function ran at all.

SecurityHeadersConfig is what CDK’s ResponseHeadersPolicy construct synthesizes from securityHeadersBehavior, and every one of its sections is modelled. ContentSecurityPolicy, ContentTypeOptions, FrameOptions, ReferrerPolicy, StrictTransportSecurity and XSSProtection each become the header CloudFront documents for it, honouring the section’s own Override the same way a CustomHeadersConfig item does.

ServerTimingHeadersConfig adds a Server-Timing header once Enabled is true. SamplingRate is ignored. This simulation adds the header to every response. A test asserting on it never depends on chance, and the header’s value is a fixed placeholder in place of real Origin timing.

CorsConfig is what CDK’s corsBehavior synthesizes. CloudFront reflects the viewer request’s Origin header against AccessControlAllowOrigins, in place of sending the list itself. A request naming an Origin the list allows gets the CORS headers the section configures, with the response varying on Origin unless the list contains *. A request naming one the list omits gets none of them, matching CloudFront, which sends none in preference to a mismatched one. AccessControlAllowMethods of ["ALL"] expands to CloudFront’s full method list, and AccessControlAllowCredentials: false leaves Access-Control-Allow-Credentials off entirely, since a header naming false means the same as its absence to a browser.

AccessControlAllowMethods, AccessControlAllowHeaders and AccessControlMaxAgeSec answer what a preflight asks, and their headers go on a response to an OPTIONS request alone. Every other method is answered without them, as CloudFront answers it. Access-Control-Allow-Origin, Access-Control-Allow-Credentials and Access-Control-Expose-Headers come back either way, and so does the Vary: Origin a reflected Origin carries. A list left empty sends no header at all, which is how SimpleCORS answers with the Origin header by itself.

An allow-list entry may use the wildcard on its own, meaning every Origin, or as the leftmost subdomain, so *.example.org matches https://site.example.org. It stands for exactly one label, as a wildcard certificate does, and it leaves https://deep.site.example.org unmatched. An entry naming no scheme matches the host whichever scheme the request used. CloudFront allows the wildcard nowhere else, and an entry placing one elsewhere (example.*, test.*.example.org, *test.example.org, exa*mple.org) fails the stack.

OriginOverride decides the whole CORS section at once, where the Override on a custom or security header decides one header. Without it, an Origin response carrying any CORS header at all, named by the policy or otherwise, keeps every header the section would have set off the response.

CloudFront’s five managed policies are here from the start, under the IDs AWS publishes, and a Behavior names one without a template creating anything. SecurityHeadersPolicy (67f7725c-6f97-4210-82d7-5512b31e9d03), SimpleCORS (60669652-455b-4ae9-85a4-c4c02393f86c), CORS-With-Preflight (5cc3b908-e619-4b99-88e5-2cf7f45965bd), CORS-and-SecurityHeadersPolicy (e61eb60c-9c35-4d20-a928-2b84e02af89c) and CORS-with-preflight-and-SecurityHeadersPolicy (eaab4381-ed33-4a86-88ca-d9558dc6cd63) each carry the sections AWS documents for it. CDK’s ResponseHeadersPolicy.SECURITY_HEADERS and its four siblings synthesize those IDs, so a stack reaching for one deploys and serves the headers.

The managed policies sit in CloudFront’s own namespace. A template may create a policy called SecurityHeadersPolicy of its own, and deleting that stack leaves the managed one where it was.

A CloudFormation Distribution whose Behavior names a policy that is neither managed nor created here deploys without one. The ResponseHeadersPolicyId lands on stack.ignoredProperties under that Behavior, and the Behavior serves every response without the headers the policy would have set. A template naming a policy from a real account, or one another stack created, is ordinary, and a site that failed to deploy over a set of headers would cost a local dev server and a test suite every request they make. One Behavior losing its policy leaves the others holding theirs, and a path Behavior is recorded under its PathPattern, the way a skipped Lambda@Edge association is.

CreateDistribution and UpdateDistribution still refuse the same ID, as real CloudFront refuses it.

A cache policy controls the cache key and time to live. Declare one as AWS::CloudFront::CachePolicy and reference it from the Behavior’s CachePolicyId. CDK’s CachePolicy construct synthesizes this shape.

/**
* Reading back the cache policy a Behavior was given.
*/
import { GetDistributionCommand } from "@aws-sdk/client-cloudfront";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const stack = await simAws.cloudFormation().deployTemplate({
stackName: "site-stack",
template: {
Resources: {
SiteBucket: {
Type: "AWS::S3::Bucket",
Properties: { BucketName: "site-bucket" },
},
BeaconPolicy: {
Type: "AWS::CloudFront::CachePolicy",
Properties: {
CachePolicyConfig: {
Name: "BeaconPolicy",
MinTTL: 0,
DefaultTTL: 60,
MaxTTL: 3600,
ParametersInCacheKeyAndForwardedToOrigin: {
EnableAcceptEncodingGzip: true,
CookiesConfig: { CookieBehavior: "none" },
HeadersConfig: { HeaderBehavior: "none" },
QueryStringsConfig: {
QueryStringBehavior: "whitelist",
QueryStrings: ["page"],
},
},
},
},
},
SiteDistribution: {
Type: "AWS::CloudFront::Distribution",
DependsOn: ["SiteBucket", "BeaconPolicy"],
Properties: {
DistributionConfig: {
DefaultRootObject: "index.html",
Origins: [
{
Id: "SiteOrigin",
DomainName: "site-bucket.s3.amazonaws.com",
S3OriginConfig: {},
},
],
DefaultCacheBehavior: {
TargetOriginId: "SiteOrigin",
ViewerProtocolPolicy: "allow-all",
// CachingOptimized, one of CloudFront's managed policies.
CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6",
},
CacheBehaviors: [
{
PathPattern: "/beacon",
TargetOriginId: "SiteOrigin",
ViewerProtocolPolicy: "allow-all",
CachePolicyId: { Ref: "BeaconPolicy" },
},
],
},
},
},
},
Outputs: {
DistributionId: { Value: { Ref: "SiteDistribution" } },
BeaconPolicyId: { Value: { Ref: "BeaconPolicy" } },
},
},
});
await stack.waitForDeployComplete();
const read = await simAws
.cloudFront()
.getDistribution(
new GetDistributionCommand({ Id: stack.output("DistributionId") }),
);
const config = read.Distribution?.DistributionConfig;
// The managed ID the default Behavior was given.
console.log(config?.DefaultCacheBehavior?.CachePolicyId);
// The ID of the policy the template created, which the path Behavior Refs.
console.log(
config?.CacheBehaviors?.Items?.[0]?.CachePolicyId ===
stack.output("BeaconPolicyId"),
);
// The policy itself, holding the TTLs and the cache key the template gave it.
const beaconPolicy = simAws
.cloudFront()
.getCachePolicyById(stack.output("BeaconPolicyId"));
console.log(beaconPolicy?.defaultTtlSec); // 60
console.log(beaconPolicy?.cacheKey.queryStringBehavior); // "whitelist"
console.log(beaconPolicy?.cacheKey.queryStrings); // ["page"]

A Behavior records the ID it was given, and GetDistribution reports it back for both the default Behavior and a named one. An update changing the policy is reported the same way.

The policy itself carries everything CachePolicyConfig holds. getCachePolicyById hands back the three TTLs, the three sections of ParametersInCacheKeyAndForwardedToOrigin and the two EnableAcceptEncoding flags. A test can assert that its Behavior leaves the query string out of the cache key before sim CloudFront has a cache to key.

The Distribution reads the policy on every request. The cache key comes from the three sections and the two flags, and a MaxTTL of zero is what makes a Behavior on CachingDisabled reach the Origin every time. Caching below covers what is stored and what is keyed on.

A TTL the template left out falls back to CloudFront’s own default (0 seconds for MinTTL, one day for DefaultTTL and 365 days for MaxTTL), and a MinTTL above a day raises the DefaultTTL with it the way CloudFront does. An absent cache key section falls back to none. A section naming a behaviour CloudFront does not offer fails the Stack, naming the Resource. HeaderBehavior takes none and whitelist alone, where CloudFront gives an origin request policy a wider set.

A policy name is unique within an account, as it is in CloudFront. A second AWS::CloudFront::CachePolicy claiming a name is refused with CachePolicyAlreadyExists.

CloudFront’s seven managed policies are here from the start, under the IDs AWS publishes, and a Behavior names one without a template creating anything. CachingOptimized (658327ea-f89d-4fab-a63d-7e88639e58f6), CachingDisabled (4135ea2d-6df8-44a3-9df3-4b5a84be39ad), CachingOptimizedForUncompressedObjects (b2884449-e4de-46a7-ac36-70bc7f1ddd6d), Amplify (2e54312d-136d-493c-8eb9-b001f22f67d2), Elemental-MediaPackage (08627262-05a9-4f76-9ded-b50ca2e3a84f), UseOriginCacheControlHeaders (83da9c7e-98b4-4e11-a168-04f0df8e2c65) and UseOriginCacheControlHeaders-QueryStrings (4cc15a8a-d715-48a4-82b8-cc0b614638fe) are each held under the name AWS publishes for it. CDK’s CachePolicy.CACHING_OPTIMIZED and its six siblings synthesize those IDs. A stack reaching for one deploys.

Each also carries the TTLs and the cache key AWS publishes for it. A Behavior on CachingOptimized here holds the same 1 second floor, the same day of default TTL and the same empty cache key as one in an account.

The managed policies sit in CloudFront’s own namespace. A template may create a policy called CachingDisabled of its own, and deleting that stack leaves the managed one where it was.

A CloudFormation Distribution whose Behavior names a policy that is neither managed nor created here deploys without one. The CachePolicyId lands on stack.ignoredProperties under that Behavior, the way an absent response headers policy does, and the Behavior reports no policy.

CreateDistribution and UpdateDistribution still refuse the same ID with NoSuchCachePolicy, as real CloudFront refuses it.

A Distribution holds a cache. A request for a key it already holds is answered from that cache, leaving the Origin unread. The Behavior’s cache policy decides the key, and decides whether the Behavior has one at all. The policy and the Origin’s own cache headers together decide how long an answer is held.

/**
* Serving a request from a Distribution's cache, and missing it at another
* edge.
*/
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
import {
CreateBucketCommand,
PutBucketPolicyCommand,
PutObjectCommand,
PutPublicAccessBlockCommand,
} from "@aws-sdk/client-s3";
import { SimAws } from "@kensio/yulin";
import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
const srv = await serveSimAws({ simAws });
try {
const simS3 = simAws.s3();
await simS3.createBucket(new CreateBucketCommand({ Bucket: "site-bucket" }));
await simS3.putPublicAccessBlock(
new PutPublicAccessBlockCommand({
Bucket: "site-bucket",
PublicAccessBlockConfiguration: {
BlockPublicAcls: true,
IgnorePublicAcls: true,
},
}),
);
await simS3.putBucketPolicy(
new PutBucketPolicyCommand({
Bucket: "site-bucket",
Policy: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: "*",
Action: "s3:GetObject",
Resource: "arn:aws:s3:::site-bucket/*",
},
}),
}),
);
const publish = async (body: string): Promise<void> => {
await simS3.putObject(
new PutObjectCommand({
Bucket: "site-bucket",
Key: "index.html",
ContentType: "text/html",
Body: body,
}),
);
};
await publish("<h1>First</h1>");
const distributionCreation = await simAws.cloudFront().createDistribution(
new CreateDistributionCommand({
DistributionConfig: {
CallerReference: "cached-site",
Comment: "Cached site",
Enabled: true,
DefaultRootObject: "index.html",
Origins: {
Quantity: 1,
Items: [
{
Id: "site-origin",
DomainName: "site-bucket.s3.amazonaws.com",
S3OriginConfig: { OriginAccessIdentity: "" },
},
],
},
DefaultCacheBehavior: {
TargetOriginId: "site-origin",
ViewerProtocolPolicy: "allow-all",
// CachingOptimized, one of CloudFront's managed policies.
CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6",
},
},
}),
);
const distroHostname = distributionCreation.Distribution!.DomainName!;
const home = srv.localUrl(`http://${distroHostname}/`);
const first = await fetch(home);
console.log(first.headers.get("x-cache")); // Miss from cloudfront
console.log(await first.text()); // <h1>First</h1>
// The Bucket holds a new page, which the Distribution has not been told
// about.
await publish("<h1>Second</h1>");
const second = await fetch(home);
console.log(second.headers.get("x-cache")); // Hit from cloudfront
console.log(await second.text()); // <h1>First</h1>
// The entry goes on ageing while simulated time moves.
await simAws.clock().advanceBy({ seconds: 30 });
const aged = await fetch(home);
console.log(aged.headers.get("age")); // 30
// Another point of presence has nothing cached under that key.
const coldEdge = await fetch(home, {
headers: { "x-sim-aws-cloudfront-edge": "second-edge" },
});
console.log(await coldEdge.text()); // <h1>Second</h1>
// With caching off, every request reaches the Origin.
simAws.cloudFront().configureCaching({ enabled: false });
const uncached = await fetch(home);
console.log(await uncached.text()); // <h1>Second</h1>
} finally {
await srv.close();
}

The key is the request path, plus whatever the Behavior’s cache policy names. A query string, a header or a cookie the policy lists joins the key. Two requests differing only in a campaign parameter the policy leaves out share one entry. Where the policy enables gzip or brotli, the normalized Accept-Encoding joins the key, and one object is cached once compressed and once plain.

The request method is part of the key as well, since a HEAD response carries no body and a GET response does. CachedMethods on the Behavior decides which methods are cached at all. It is GET and HEAD unless the Behavior widens it, and a POST reaches the Origin every time.

CloudFront caches at each of its points of presence, several hundred of them, and one viewer’s request fills the cache at one of them. The key carries an edge ID for that. Every request arrives at the same edge unless it sends an x-sim-aws-cloudfront-edge header naming another. A test that sends a different one is proving its app survives arriving somewhere cold.

The header follows the x-sim-aws-* convention of the caller and request source headers, and @kensio/yulin/cloudfront exports the name as simCfEdgeHeaderName. Sim CloudFront takes it off the request once it has read it. A web ACL rule, a CloudFront Function, a Lambda@Edge function and the Origin all see the request the viewer sent without it.

A Behavior stores what it serves where its cache policy is one this simulation holds and that policy’s MaxTTL is above zero. That leaves out a Behavior naming a CachePolicyId from a real account, a Behavior naming none at all, and a Behavior on CachingDisabled. The alternative in each case would be a guessed TTL.

An error is held for a TTL of its own. It comes from the ErrorCachingMinTTL of the custom error response matching the status, and from CloudFront’s ten seconds where the Distribution configures no rule for that status. The Origin’s cache headers and the Behavior’s cache policy have no say in it. A rule with ErrorCachingMinTTL: 0 holds the error for no time at all, and every failing request reaches the Origin. A rule is matched on the status the Origin answered with. A 404 the Distribution serves as a 200 error page is held for the seconds the 404’s own rule allows.

An entry records the instant it expires, taken from the simulation’s clock. A request arriving after that instant reaches the Origin, and what the Origin answers takes the expired entry’s place. So a test reaches the moment its content goes stale by advancing simulated time, without waiting for it.

/**
* Expiring a cached object by moving simulated time past its cache control.
*/
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
import {
CreateBucketCommand,
PutBucketPolicyCommand,
PutObjectCommand,
PutPublicAccessBlockCommand,
} from "@aws-sdk/client-s3";
import { SimAws } from "@kensio/yulin";
import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
const srv = await serveSimAws({ simAws });
try {
const simS3 = simAws.s3();
await simS3.createBucket(new CreateBucketCommand({ Bucket: "news-bucket" }));
await simS3.putPublicAccessBlock(
new PutPublicAccessBlockCommand({
Bucket: "news-bucket",
PublicAccessBlockConfiguration: {
BlockPublicAcls: true,
IgnorePublicAcls: true,
},
}),
);
await simS3.putBucketPolicy(
new PutBucketPolicyCommand({
Bucket: "news-bucket",
Policy: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: "*",
Action: "s3:GetObject",
Resource: "arn:aws:s3:::news-bucket/*",
},
}),
}),
);
// The Origin holds each version of the page for a minute.
const publish = async (body: string): Promise<void> => {
await simS3.putObject(
new PutObjectCommand({
Bucket: "news-bucket",
Key: "index.html",
ContentType: "text/html",
CacheControl: "max-age=60",
Body: body,
}),
);
};
await publish("<h1>First</h1>");
const distributionCreation = await simAws.cloudFront().createDistribution(
new CreateDistributionCommand({
DistributionConfig: {
CallerReference: "news-site",
Comment: "News site",
Enabled: true,
DefaultRootObject: "index.html",
Origins: {
Quantity: 1,
Items: [
{
Id: "news-origin",
DomainName: "news-bucket.s3.amazonaws.com",
S3OriginConfig: { OriginAccessIdentity: "" },
},
],
},
DefaultCacheBehavior: {
TargetOriginId: "news-origin",
ViewerProtocolPolicy: "allow-all",
// CachingOptimized, one of CloudFront's managed policies.
CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6",
},
},
}),
);
const distroHostname = distributionCreation.Distribution!.DomainName!;
const home = srv.localUrl(`http://${distroHostname}/`);
const first = await fetch(home);
console.log(await first.text()); // <h1>First</h1>
await publish("<h1>Second</h1>");
// Still inside the minute the Origin asked for.
const held = await fetch(home);
console.log(await held.text()); // <h1>First</h1>
// Simulated time moves past it, and the next request reaches the Origin.
await simAws.clock().advanceBy({ seconds: 61 });
const expired = await fetch(home);
console.log(await expired.text()); // <h1>Second</h1>
} finally {
await srv.close();
}

The Origin’s own headers and the cache policy settle the TTL between them, the way they settle it in AWS. s-maxage is preferred to max-age, and max-age to Expires. Whatever the Origin asks for is held between the policy’s MinTTL and MaxTTL. When the Origin supplies no cache lifetime, CloudFront uses the greater of MinTTL and DefaultTTL. CachingOptimized uses one day.

An Expires header has to carry one of the three date formats HTTP allows. Anything else, a locale-formatted date included, is read as an object that expired already, the way any HTTP cache reads it.

no-store, no-cache and private keep the answer out of the cache while the policy’s MinTTL is zero. Where it is higher, the floor overrides the Origin and the answer is held for MinTTL seconds. That last one is CloudFront’s own behaviour, and the AWS documentation carries a warning about it.

An answer the cache or the Origin produced carries X-Cache. It reads Hit from cloudfront where the Distribution held the object and Miss from cloudfront where the Origin answered. A response returned earlier in the pipeline (one a web ACL blocked, or one a viewer-request function answered with) carries none. A hit carries Age as well, the whole seconds the entry has been held, counted on the simulation’s clock from the moment the Origin answered. Advancing simulated time by a minute adds 60 to the age the next hit reports.

Both headers go on ahead of the Behavior’s response headers policy and the viewer-response event. A policy listing X-Cache among its headers to remove takes it off, and a viewer-response CloudFront Function or Lambda@Edge function reads both in its event.

A hit is answered without running either origin event. origin-request and origin-response run on the miss that filled the cache and on nothing after it, as they do on AWS. The viewer events run either way, and so does the Behavior’s response headers policy.

A response headers policy is applied to a response on its way out of the cache, as CloudFront applies one. The stored entry carries the Origin’s own headers. A Behavior given a different policy therefore serves what it already holds under the new one, with no invalidation.

simAws.cloudFront().configureCaching({ enabled: false }) turns caching off for every Distribution in one SimAws, across every Account and Region. Caching is on by default, as CloudFront’s is. A suite that repeats a request and wants each one to reach the Origin can turn it off in setup.

CreateInvalidation clears what a Distribution has cached. The next request for a cleared path reaches the Origin. This is the step a deploy takes once it has published new files, and it decides whether anyone sees them.

/**
* Clearing what a Distribution has cached, and reading the invalidation back.
*/
import {
CreateDistributionCommand,
CreateInvalidationCommand,
GetInvalidationCommand,
ListInvalidationsCommand,
} from "@aws-sdk/client-cloudfront";
import {
CreateBucketCommand,
PutBucketPolicyCommand,
PutObjectCommand,
PutPublicAccessBlockCommand,
} from "@aws-sdk/client-s3";
import { SimAws } from "@kensio/yulin";
import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
const srv = await serveSimAws({ simAws });
try {
const simS3 = simAws.s3();
await simS3.createBucket(
new CreateBucketCommand({ Bucket: "release-bucket" }),
);
await simS3.putPublicAccessBlock(
new PutPublicAccessBlockCommand({
Bucket: "release-bucket",
PublicAccessBlockConfiguration: {
BlockPublicAcls: true,
IgnorePublicAcls: true,
},
}),
);
await simS3.putBucketPolicy(
new PutBucketPolicyCommand({
Bucket: "release-bucket",
Policy: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: "*",
Action: "s3:GetObject",
Resource: "arn:aws:s3:::release-bucket/*",
},
}),
}),
);
const publish = async (body: string): Promise<void> => {
await simS3.putObject(
new PutObjectCommand({
Bucket: "release-bucket",
Key: "index.html",
ContentType: "text/html",
Body: body,
}),
);
};
await publish("<h1>First</h1>");
const simCloudFront = simAws.cloudFront();
const distributionCreation = await simCloudFront.createDistribution(
new CreateDistributionCommand({
DistributionConfig: {
CallerReference: "released-site",
Comment: "Released site",
Enabled: true,
DefaultRootObject: "index.html",
Origins: {
Quantity: 1,
Items: [
{
Id: "site-origin",
DomainName: "release-bucket.s3.amazonaws.com",
S3OriginConfig: { OriginAccessIdentity: "" },
},
],
},
DefaultCacheBehavior: {
TargetOriginId: "site-origin",
ViewerProtocolPolicy: "allow-all",
// CachingOptimized, one of CloudFront's managed policies.
CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6",
},
},
}),
);
const distributionId = distributionCreation.Distribution!.Id!;
const distroHostname = distributionCreation.Distribution!.DomainName!;
const home = srv.localUrl(`http://${distroHostname}/`);
const first = await fetch(home);
console.log(await first.text()); // <h1>First</h1>
// The deploy publishes a new page, which the Distribution is still holding
// the old version of.
await publish("<h1>Second</h1>");
const stale = await fetch(home);
console.log(await stale.text()); // <h1>First</h1>
const creation = await simCloudFront.createInvalidation(
new CreateInvalidationCommand({
DistributionId: distributionId,
InvalidationBatch: {
CallerReference: "deployment-1",
Paths: { Quantity: 1, Items: ["/*"] },
},
}),
);
console.log(creation.Invalidation!.Status); // InProgress
const released = await fetch(home);
console.log(await released.text()); // <h1>Second</h1>
// The invalidation finishes on the background scheduler.
await simAws.backgroundTasksComplete();
const invalidation = await simCloudFront.getInvalidation(
new GetInvalidationCommand({
DistributionId: distributionId,
Id: creation.Invalidation!.Id,
}),
);
console.log(invalidation.Invalidation!.Status); // Completed
const listing = await simCloudFront.listInvalidations(
new ListInvalidationsCommand({ DistributionId: distributionId }),
);
console.log(listing.InvalidationList!.Quantity); // 1
} finally {
await srv.close();
}

A path names one object. A path ending in a wildcard names everything below what comes before it. /images/* clears the images and leaves /index.html where it was, and /* clears everything the Distribution holds. A path arriving without its leading slash is read as though it had one. The bare * a console user types is the same batch as /*.

An invalidation reaches every edge. A page cached at three points of presence is cleared at all three, whichever edge the requests that filled them arrived at.

The entries go as the invalidation is created. Real CloudFront clears each point of presence over the following seconds, and a test waiting for that before asking again would be waiting on the simulator.

An invalidation starts InProgress and reaches Completed on the background scheduler, the way a Distribution reaches Deployed. GetDistribution counts the running ones as InProgressInvalidationBatches.

GetInvalidation answers with the batch of paths the invalidation was created from. ListInvalidations answers with the Distribution’s whole list, most recently created first. An invalidation ID the Distribution has never held is NoSuchInvalidation.

CallerReference makes a batch idempotent. Sending one twice with the same paths answers with the invalidation that reference already created, and clears nothing a second time. Sending it with different paths is InvalidationBatchAlreadyExists.

An origin request policy selects the viewer headers, cookies and query strings sent to the Origin. Declare one as AWS::CloudFront::OriginRequestPolicy and reference it from the Behavior’s OriginRequestPolicyId. CDK’s OriginRequestPolicy construct synthesizes this shape.

/**
* Reading back the origin request policy a Behavior was given.
*/
import { GetDistributionCommand } from "@aws-sdk/client-cloudfront";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const stack = await simAws.cloudFormation().deployTemplate({
stackName: "site-stack",
template: {
Resources: {
SiteBucket: {
Type: "AWS::S3::Bucket",
Properties: { BucketName: "site-bucket" },
},
BeaconPolicy: {
Type: "AWS::CloudFront::OriginRequestPolicy",
Properties: {
OriginRequestPolicyConfig: {
Name: "BeaconPolicy",
CookiesConfig: { CookieBehavior: "none" },
HeadersConfig: { HeaderBehavior: "none" },
QueryStringsConfig: { QueryStringBehavior: "all" },
},
},
},
SiteDistribution: {
Type: "AWS::CloudFront::Distribution",
DependsOn: ["SiteBucket", "BeaconPolicy"],
Properties: {
DistributionConfig: {
DefaultRootObject: "index.html",
Origins: [
{
Id: "SiteOrigin",
DomainName: "site-bucket.s3.amazonaws.com",
S3OriginConfig: {},
},
],
DefaultCacheBehavior: {
TargetOriginId: "SiteOrigin",
ViewerProtocolPolicy: "allow-all",
// CORS-S3Origin, one of CloudFront's managed policies.
OriginRequestPolicyId: "88a5eaf4-2fd4-4709-b370-b4c650ea3fcf",
},
CacheBehaviors: [
{
PathPattern: "/beacon",
TargetOriginId: "SiteOrigin",
ViewerProtocolPolicy: "allow-all",
OriginRequestPolicyId: { Ref: "BeaconPolicy" },
},
],
},
},
},
},
Outputs: {
DistributionId: { Value: { Ref: "SiteDistribution" } },
BeaconPolicyId: { Value: { Ref: "BeaconPolicy" } },
},
},
});
await stack.waitForDeployComplete();
const read = await simAws
.cloudFront()
.getDistribution(
new GetDistributionCommand({ Id: stack.output("DistributionId") }),
);
const config = read.Distribution?.DistributionConfig;
// The managed ID the default Behavior was given.
console.log(config?.DefaultCacheBehavior?.OriginRequestPolicyId);
// The ID of the policy the template created, which the path Behavior Refs.
console.log(
config?.CacheBehaviors?.Items?.[0]?.OriginRequestPolicyId ===
stack.output("BeaconPolicyId"),
);

A Behavior records the ID it was given, and GetDistribution reports it back for both the default Behavior and a named one. An update changing the policy is reported the same way.

A policy name is unique within an account, as it is in CloudFront. A second AWS::CloudFront::OriginRequestPolicy claiming a name is refused with OriginRequestPolicyAlreadyExists.

HeadersConfig, CookiesConfig and QueryStringsConfig are read along with Name and Comment. CookieBehavior and QueryStringBehavior each take none, whitelist, allExcept and all. HeaderBehavior takes none, whitelist, allExcept, allViewer and allViewerAndWhitelistCloudFront. An absent section falls back to none. A section naming a behaviour CloudFront does not offer fails the Stack, naming the Resource.

A custom Origin is sent the headers, cookies and query strings the Behavior’s cache policy and origin request policy name between them, and none of the rest of the viewer’s request. That union is what real CloudFront sends. The cache policy half is in it because an Origin has to be able to answer for the key its response is stored under.

Alongside it CloudFront sends what it sends of its own accord. Host is the Origin’s own domain, whatever the policies name (a viewer’s own Host never reaches an Origin here, so AllViewerExceptHostHeader and AllViewer reach a Function URL Origin alike). User-Agent is Amazon CloudFront unless the policies carry the viewer’s own. Accept-Encoding is the normalized gzip, br or gzip, br the cache policy’s EnableAcceptEncoding flags asked for. Content-Length, Content-Type and Transfer-Encoding describe the body and travel with it. The Origin’s custom headers and its origin access control’s signing headers are applied on top.

/**
* What a custom Origin reads of the viewer's request, with and without an
* origin request policy.
*/
import {
CreateApiCommand,
CreateIntegrationCommand,
CreateRouteCommand,
CreateStageCommand,
} from "@aws-sdk/client-apigatewayv2";
import { CreateDistributionCommand } from "@aws-sdk/client-cloudfront";
import {
AddPermissionCommand,
CreateFunctionCommand,
} from "@aws-sdk/client-lambda";
import { SimAws } from "@kensio/yulin";
import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
// A function reporting the query string and the user agent it was asked with.
const { FunctionArn } = await simAws.lambda().createFunction(
new CreateFunctionCommand({
FunctionName: "search",
Role: "arn:aws:iam::111111111111:role/SearchRole",
Code: {
ZipFile: makeLambdaZipFileInput(
(event: {
rawQueryString: string;
headers: Record<string, string>;
}) => ({
query: event.rawQueryString,
userAgent: event.headers["user-agent"],
}),
),
},
}),
);
const apiGateway = simAws.apiGatewayV2();
const { ApiId, ApiEndpoint } = await apiGateway.createApi(
new CreateApiCommand({ Name: "search", ProtocolType: "HTTP" }),
);
const { IntegrationId } = await apiGateway.createIntegration(
new CreateIntegrationCommand({
ApiId,
IntegrationType: "AWS_PROXY",
IntegrationUri: FunctionArn,
PayloadFormatVersion: "2.0",
}),
);
await apiGateway.createRoute(
new CreateRouteCommand({
ApiId,
RouteKey: "GET /search",
Target: `integrations/${IntegrationId}`,
}),
);
await apiGateway.createRoute(
new CreateRouteCommand({
ApiId,
RouteKey: "GET /open/search",
Target: `integrations/${IntegrationId}`,
}),
);
await apiGateway.createStage(
new CreateStageCommand({ ApiId, StageName: "$default", AutoDeploy: true }),
);
await simAws.lambda().addPermission(
new AddPermissionCommand({
FunctionName: "search",
StatementId: "api-gateway-invoke",
Action: "lambda:InvokeFunction",
Principal: "apigateway.amazonaws.com",
SourceArn: `arn:aws:execute-api:us-east-1:888888888888:${ApiId}/*/*`,
}),
);
// Two Behaviors on the same Origin. The default names no policy, and /open/*
// names AllViewer, one of CloudFront's managed policies.
const distributionCreation = await simAws.cloudFront().createDistribution(
new CreateDistributionCommand({
DistributionConfig: {
CallerReference: "search-site",
Comment: "Search CDN",
Enabled: true,
Origins: {
Quantity: 1,
Items: [
{
Id: "api-origin",
DomainName: new URL(ApiEndpoint).hostname,
CustomOriginConfig: {
HTTPPort: 80,
HTTPSPort: 443,
OriginProtocolPolicy: "https-only",
},
},
],
},
DefaultCacheBehavior: {
TargetOriginId: "api-origin",
ViewerProtocolPolicy: "allow-all",
},
CacheBehaviors: {
Quantity: 1,
Items: [
{
PathPattern: "/open/*",
TargetOriginId: "api-origin",
ViewerProtocolPolicy: "allow-all",
OriginRequestPolicyId: "216adef6-5c7f-47e4-b989-5492eafa07d3",
},
],
},
},
}),
);
const distroHostname = distributionCreation.Distribution!.DomainName!;
const srv = await serveSimAws({ simAws });
try {
const withheld = await fetch(
srv.localUrl(`http://${distroHostname}/search?q=kettle`),
{ headers: { "user-agent": "Firefox" } },
);
// {"query":"","userAgent":"Amazon CloudFront"}
console.log(await withheld.text());
const forwarded = await fetch(
srv.localUrl(`http://${distroHostname}/open/search?q=kettle`),
{ headers: { "user-agent": "Firefox" } },
);
// {"query":"q=kettle","userAgent":"Firefox"}
console.log(await forwarded.text());
} finally {
await srv.close();
}

A Behavior naming neither policy sends the path and nothing else. That is a Distribution CloudFront would serve the same way, and an Origin reading a query string behind one is a bug worth a failing test.

An S3 Origin is unaffected. It reads its Bucket through GetObject and builds no request to narrow.

CloudFront’s eight managed policies are here from the start, under the IDs AWS publishes, and a Behavior names one without a template creating anything. AllViewer (216adef6-5c7f-47e4-b989-5492eafa07d3), AllViewerAndCloudFrontHeaders-2022-06 (33f36d7e-f396-46d9-90e0-52428a34d9dc), AllViewerExceptHostHeader (b689b0a8-53d0-40ab-baf2-68738e2966ac), CORS-CustomOrigin (59781a5b-3903-41f3-afcb-af62929ccde1), CORS-S3Origin (88a5eaf4-2fd4-4709-b370-b4c650ea3fcf), Elemental-MediaTailor-PersonalizedManifests (775133bc-15f2-49f9-abea-afb2e0bf67d2), HostHeaderOnly (bf0718e1-ba1e-49d1-88b1-f726733018ae) and UserAgentRefererHeaders (acba4595-bd28-49b8-b9fe-13317c0390fa) are each held under the name AWS publishes for it. CDK’s OriginRequestPolicy.ALL_VIEWER and its seven siblings synthesize those IDs. A stack reaching for one deploys.

Each also carries the three sections AWS publishes for it. A Behavior on AllViewer sends every viewer value except Host. The Origin’s domain is used for Host under every policy. A Behavior on CORS-CustomOrigin sends the Origin header alone. One on AllViewerExceptHostHeader sends what AllViewer sends, since the Host it withholds was never the viewer’s here.

The managed policies sit in CloudFront’s own namespace. A template may create a policy called AllViewer of its own, and deleting that stack leaves the managed one where it was.

A CloudFormation Distribution whose Behavior names a policy that is neither managed nor created here deploys without one. The OriginRequestPolicyId lands on stack.ignoredProperties under that Behavior, the way an absent cache policy does, and the Behavior reports no policy.

CreateDistribution and UpdateDistribution still refuse the same ID with NoSuchOriginRequestPolicy, as real CloudFront refuses it.

An origin access control lets a Distribution authenticate to a private Origin. Declare one as AWS::CloudFront::OriginAccessControl and reference it from the Origin’s OriginAccessControlId. CDK’s S3BucketOrigin.withOriginAccessControl synthesizes this shape.

An OriginAccessControlOriginType of s3 signs for an S3 Bucket Origin, and one of lambda signs for a Lambda Function URL Origin. The origin type has to match the Origin it is attached to. An s3 origin access control on a custom Origin, or a lambda one on an S3 Origin, fails the Stack when the Distribution is created, as CloudFront refuses it.

An S3 Origin whose origin access control signs reads its Bucket as the cloudfront.amazonaws.com service principal, carrying the Distribution’s ARN as aws:SourceArn. The Bucket policy is then the whole decision. The Bucket needs a statement granting s3:GetObject to that principal, conditioned on the Distribution allowed to read it. That is the policy CDK writes. A condition naming a different Distribution, or an Origin that was never given an origin access control, answers 403.

/**
* Serving a private S3 Bucket through an origin access control.
*/
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { SimAws } from "@kensio/yulin";
import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
const srv = await serveSimAws({ simAws });
try {
const stack = await simAws.cloudFormation().deployTemplate({
stackName: "site-stack",
template: {
Resources: {
SiteBucket: {
Type: "AWS::S3::Bucket",
Properties: { BucketName: "site-bucket" },
},
SiteOac: {
Type: "AWS::CloudFront::OriginAccessControl",
Properties: {
OriginAccessControlConfig: {
Name: "site-oac",
OriginAccessControlOriginType: "s3",
SigningBehavior: "always",
SigningProtocol: "sigv4",
},
},
},
SiteDistribution: {
Type: "AWS::CloudFront::Distribution",
Properties: {
DistributionConfig: {
Enabled: true,
DefaultRootObject: "index.html",
Origins: [
{
Id: "SiteOrigin",
DomainName: "site-bucket.s3.amazonaws.com",
S3OriginConfig: {},
OriginAccessControlId: { Ref: "SiteOac" },
},
],
DefaultCacheBehavior: {
TargetOriginId: "SiteOrigin",
ViewerProtocolPolicy: "allow-all",
},
},
},
},
// Nothing but this Distribution may read the Bucket, which is what the
// condition on the Distribution's ARN says.
SiteBucketPolicy: {
Type: "AWS::S3::BucketPolicy",
Properties: {
Bucket: { Ref: "SiteBucket" },
PolicyDocument: {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: { Service: "cloudfront.amazonaws.com" },
Action: "s3:GetObject",
Resource: "arn:aws:s3:::site-bucket/*",
Condition: {
StringEquals: {
"AWS:SourceArn": {
"Fn::Join": [
"",
[
"arn:aws:cloudfront::",
{ Ref: "AWS::AccountId" },
":distribution/",
{ Ref: "SiteDistribution" },
],
],
},
},
},
},
],
},
},
},
},
Outputs: {
SiteHostname: {
Value: { "Fn::GetAtt": ["SiteDistribution", "DomainName"] },
},
},
},
});
await stack.waitForDeployComplete();
await simAws.s3().putObject(
new PutObjectCommand({
Bucket: "site-bucket",
Key: "index.html",
ContentType: "text/html",
Body: "<h1>Home</h1>",
}),
);
const siteHostname = stack.output("SiteHostname");
const home = await fetch(srv.localUrl(`http://${siteHostname}/`));
console.log(await home.text()); // <h1>Home</h1>
} finally {
await srv.close();
}

The Bucket policy names the Distribution’s ARN, and is created after the Distribution. The Ref inside Fn::Join is the dependency CloudFormation orders the Stack by. The read is settled per request, because the policy deciding it comes into existence after the Distribution does. The Origin works out who it is reading as each time.

Putting a Function URL with AuthType: AWS_IAM behind a Distribution takes the origin access control with OriginAccessControlOriginType: lambda, a custom Origin naming it whose DomainName is the Function URL’s hostname, and two AWS::Lambda::Permission Resources granting cloudfront.amazonaws.com for that Distribution. It is the only way to serve a Function URL through CloudFront without leaving the Function URL open to anyone who finds its endpoint.

Both permissions are needed. One grants lambda:InvokeFunctionUrl and the other lambda:InvokeFunction, to the same principal with the same SourceArn, as Restrict access to an AWS Lambda function URL origin sets out. CDK’s FunctionUrlOrigin.withOriginAccessControl writes only the first. A CDK app has to add the second itself:

greeterFunction.addPermission("InvokeFunctionFromCloudFront", {
principal: new iam.ServicePrincipal("cloudfront.amazonaws.com"),
action: "lambda:InvokeFunction",
sourceArn: cdk.Fn.join("", [
"arn:",
cdk.Aws.PARTITION,
":cloudfront::",
cdk.Aws.ACCOUNT_ID,
":distribution/",
distribution.distributionId,
]),
});

The Origin request is made as the cloudfront.amazonaws.com service principal carrying the Distribution’s ARN, the same pair an S3 Origin read carries, and the function’s resource policy is the whole decision. A Stack missing either permission, or with one naming a different Distribution, deploys and then answers 403 through the Distribution, as the real deployment does. The function is never invoked, and writes no logs to look at either.

/**
* Serving a private Lambda Function URL through an origin access control.
*/
import { SimAws } from "@kensio/yulin";
import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws();
const srv = await serveSimAws({ simAws });
try {
const stack = await simAws.cloudFormation().deployTemplate({
stackName: "greeter-stack",
template: {
Resources: {
GreeterFunction: {
Type: "AWS::Lambda::Function",
Properties: {
FunctionName: "greeter",
Role: "arn:aws:iam::888888888888:role/GreeterRole",
Handler: "index.handler",
Runtime: "nodejs22.x",
Code: {
ZipFile:
"exports.handler = async () => " +
"({ statusCode: 200, body: 'Hello from behind CloudFront' });",
},
},
},
GreeterUrl: {
Type: "AWS::Lambda::Url",
Properties: {
TargetFunctionArn: { "Fn::GetAtt": ["GreeterFunction", "Arn"] },
AuthType: "AWS_IAM",
},
},
GreeterOac: {
Type: "AWS::CloudFront::OriginAccessControl",
Properties: {
OriginAccessControlConfig: {
Name: "greeter-oac",
OriginAccessControlOriginType: "lambda",
SigningBehavior: "always",
SigningProtocol: "sigv4",
},
},
},
GreeterDistribution: {
Type: "AWS::CloudFront::Distribution",
Properties: {
DistributionConfig: {
Enabled: true,
Origins: [
{
Id: "GreeterOrigin",
// An Origin takes a domain name, and the Function URL
// attribute is a URL, so the host comes out of it.
DomainName: {
"Fn::Select": [
2,
{
"Fn::Split": [
"/",
{ "Fn::GetAtt": ["GreeterUrl", "FunctionUrl"] },
],
},
],
},
CustomOriginConfig: { OriginProtocolPolicy: "https-only" },
OriginAccessControlId: { Ref: "GreeterOac" },
},
],
DefaultCacheBehavior: {
TargetOriginId: "GreeterOrigin",
ViewerProtocolPolicy: "allow-all",
},
},
},
},
// Nothing but this Distribution may invoke the Function URL, which is
// what the condition on the Distribution's ARN says. Reaching the URL
// takes both actions, so leaving either one out is a 403.
InvokeFunctionUrlFromCloudFront: {
Type: "AWS::Lambda::Permission",
Properties: {
FunctionName: { "Fn::GetAtt": ["GreeterFunction", "Arn"] },
Action: "lambda:InvokeFunctionUrl",
Principal: "cloudfront.amazonaws.com",
SourceArn: {
"Fn::Join": [
"",
[
"arn:aws:cloudfront::",
{ Ref: "AWS::AccountId" },
":distribution/",
{ Ref: "GreeterDistribution" },
],
],
},
},
},
InvokeFunctionFromCloudFront: {
Type: "AWS::Lambda::Permission",
Properties: {
FunctionName: { "Fn::GetAtt": ["GreeterFunction", "Arn"] },
Action: "lambda:InvokeFunction",
Principal: "cloudfront.amazonaws.com",
SourceArn: {
"Fn::Join": [
"",
[
"arn:aws:cloudfront::",
{ Ref: "AWS::AccountId" },
":distribution/",
{ Ref: "GreeterDistribution" },
],
],
},
},
},
},
Outputs: {
SiteHostname: {
Value: { "Fn::GetAtt": ["GreeterDistribution", "DomainName"] },
},
},
},
});
await stack.waitForDeployComplete();
const siteHostname = stack.output("SiteHostname");
const greeting = await fetch(srv.localUrl(`http://${siteHostname}/greeting`));
console.log(await greeting.text()); // Hello from behind CloudFront
} finally {
await srv.close();
}

The Function URL is reachable directly as well, on its own endpoint, and it refuses a request that arrives there without the permission the Distribution has. That is the point of the auth type. The endpoint exists, and only the Distribution may use it.

SigningBehavior takes any of always, never and no-override. always and no-override both sign, since nothing here sends a pre-signed viewer request to an Origin for no-override to pass through. never turns the origin access control off while leaving it in place, and the Origin is reached anonymously, as an Origin with no origin access control is. An S3 Origin then needs a Bucket policy allowing that, and an AWS_IAM Function URL refuses the request outright.

Ref and Fn::GetAtt on Id both return the ID, so either resolves an Origin’s OriginAccessControlId. An Origin naming an ID no origin access control holds is refused with InvalidOriginAccessControl when the Distribution is created. Tearing the Stack down removes the origin access control, and its name is free again.

OriginAccessControlOriginType must be s3 or lambda, and SigningProtocol must be sigv4. Any other value fails the Stack by name.

A CloudFormation template is the only way to make one. There is no CreateOriginAccessControl command here.

A POST or PUT through an origin access control has to carry the SHA-256 of its body in an x-amz-content-sha256 header. CloudFront streams the viewer’s body on to the Origin without buffering it, and has no hash of its own to sign with. It signs the hash the viewer declared, and UNSIGNED-PAYLOAD where the viewer declared none. Lambda supports no unsigned payload, and answers 403 with The request signature we calculated does not match the signature you provided. The handler never runs. The declared hash is checked against the body that arrived, and a digest of other bytes is refused the same way.

A viewer computes the digest of what it is about to send, the way any SigV4 client does:

const body = JSON.stringify({ email: "someone@example.com" });
const response = await fetch(`http://${siteHostname}/sign-in`, {
method: "POST",
body,
headers: {
"content-type": "application/json",
"x-amz-content-sha256": createHash("sha256").update(body).digest("hex"),
},
});

A GET or a HEAD is left alone. SigV4 hashes an empty payload for a request without a body, and CloudFront can sign one of those on its own. An origin access control with a SigningBehavior of never signs no Origin request, and states no payload hash for one. A POST through one reaches the Origin anonymously, as it did before.

AWS documents the requirement on Restrict access to an AWS Lambda function URL origin. A simulated Distribution refuses the request for the same reason a real one does. A form post missing the header fails in a test as well as on the deployment.

A key value store holds data a CloudFront Function reads at request time. A redirect table or a feature flag can live there instead of being baked into the Function’s code.

AWS splits this across two SDK clients, and so does the simulator. The CloudFront client owns the store, through CreateKeyValueStoreCommand, DescribeKeyValueStoreCommand, ListKeyValueStoresCommand, UpdateKeyValueStoreCommand and DeleteKeyValueStoreCommand, all addressing a store by name. The key value store client owns the data, through GetKeyCommand, PutKeyCommand, DeleteKeyCommand, ListKeysCommand, UpdateKeysCommand and its own DescribeKeyValueStoreCommand, all addressing a store by ARN.

Both clients are intercepted by SimSdk. Used directly, they are simAws.cloudFront().keyValueStores() and simAws.cloudFrontKeyValueStore().

/**
* Creating a CloudFront key value store and writing keys to it.
*/
import { CreateKeyValueStoreCommand } from "@aws-sdk/client-cloudfront";
import {
DescribeKeyValueStoreCommand,
GetKeyCommand,
UpdateKeysCommand,
} from "@aws-sdk/client-cloudfront-keyvaluestore";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
// The CloudFront client owns the store itself.
const created = await simAws
.cloudFront()
.keyValueStores()
.createKeyValueStore(
new CreateKeyValueStoreCommand({
Name: "redirects",
Comment: "Where old paths go",
}),
);
const kvsArn = created.KeyValueStore.ARN;
const data = simAws.cloudFrontKeyValueStore();
// The key value store client owns the data, and addresses the store by ARN.
// Every write carries an ETag, and it is this API's own: the one the
// CloudFront client returned above versions the resource, not the keys.
const described = await data.describeKeyValueStore(
new DescribeKeyValueStoreCommand({ KvsARN: kvsArn }),
);
const written = await data.updateKeys(
new UpdateKeysCommand({
KvsARN: kvsArn,
IfMatch: described.ETag,
Puts: [
{ Key: "/old-page", Value: "/new-page" },
{ Key: "/legacy", Value: "/current" },
],
}),
);
console.log(written.ItemCount); // 2
const read = await data.getKey(
new GetKeyCommand({ KvsARN: kvsArn, Key: "/old-page" }),
);
console.log(read.Value); // /new-page

A new store is PROVISIONING when the command returns and becomes READY in the background, as in CloudFront. await simAws.backgroundTasksComplete() waits for that.

Key value store commands check IfMatch. Distribution and Function commands ignore it. Both key value store APIs require the current ETag for every write. A stale ETag raises PreconditionFailed, and each successful write returns the ETag for the next one.

A store has two ETags and they are not interchangeable, as in AWS. Each DescribeKeyValueStore returns its own. The CloudFront client’s versions the store’s configuration, and the key value store client’s versions the keys. Writing a key leaves the configuration’s ETag where it was, and changing the comment leaves the keys’ where it was. A write carrying the other API’s ETag is refused, and the message says which of the two it wanted.

Reading a store from a CloudFront Function

Section titled “Reading a store from a CloudFront Function”

A Function reads its store through cf, which it gets from import cf from "cloudfront". That is the one import JS 2.0 has. cf.kvs() opens the store the Function is associated with, and its get, exists and meta are all promises. A Function that reads a store is async.

A Function names the store it may read with KeyValueStoreAssociations on its FunctionConfig. CloudFront takes at most one, and only on cloudfront-js-2.0. An association on the 1.0 runtime is refused, because that runtime has no cf to reach a store through.

/**
* Reading a key value store from a CloudFront Function.
*/
import {
CreateFunctionCommand,
CreateKeyValueStoreCommand,
} from "@aws-sdk/client-cloudfront";
import {
DescribeKeyValueStoreCommand,
PutKeyCommand,
} from "@aws-sdk/client-cloudfront-keyvaluestore";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const created = await simAws
.cloudFront()
.keyValueStores()
.createKeyValueStore(new CreateKeyValueStoreCommand({ Name: "redirects" }));
const kvsArn = created.KeyValueStore.ARN;
const data = simAws.cloudFrontKeyValueStore();
const described = await data.describeKeyValueStore(
new DescribeKeyValueStoreCommand({ KvsARN: kvsArn }),
);
await data.putKey(
new PutKeyCommand({
KvsARN: kvsArn,
Key: "/old-page",
Value: "/new-page",
IfMatch: described.ETag,
}),
);
// The Function names the store it may read. It gets `cf` from the one import
// JS 2.0 has, and the read is awaited, so the handler is async.
await simAws.cloudFront().createFunction(
new CreateFunctionCommand({
Name: "redirect-cff",
FunctionConfig: {
Comment: "Redirects from a key value store",
Runtime: "cloudfront-js-2.0",
KeyValueStoreAssociations: {
Quantity: 1,
Items: [{ KeyValueStoreARN: kvsArn }],
},
},
FunctionCode: Buffer.from(`
import cf from "cloudfront";
async function handler(event) {
const request = event.request;
if (await cf.kvs().exists(request.uri)) {
const target = await cf.kvs().get(request.uri);
return {
statusCode: 302,
statusDescription: "Found",
headers: { location: { value: target } },
};
}
return request;
}
`),
}),
);
const cff = simAws.cloudFront().getCloudFrontFunctionByName("redirect-cff");
const redirected = await cff!.handleViewerRequest(
new Request("https://cdn.test/old-page"),
);
console.log((redirected as Response).status); // 302
console.log((redirected as Response).headers.get("location")); // /new-page

get reads a string by default, and takes { format: "json" } to parse the stored string or { format: "bytes" } for its UTF-8 bytes. A missing key rejects. A Function that wants a default checks exists first, as the example does.

A Function written as a function reference has no import to write, and reads cf as a global. Importing @kensio/yulin/cloudfront/globals gives that global a type, along with the CloudFront Function event types. Each invocation gets its own cf through Node.js asynchronous context, and two Functions associated with different stores read their own even when they run at the same time.

AWS::CloudFront::KeyValueStore creates a store, and a Function associates one with FunctionConfig.KeyValueStoreAssociations. CloudFormation takes a plain array there, where the SDK takes a Quantity and Items pair. Ref on a key value store is its ARN, and the two fit together directly:

Redirects:
Type: AWS::CloudFront::KeyValueStore
Properties:
Name: redirects
RedirectFunction:
Type: AWS::CloudFront::Function
Properties:
Name: redirect-cff
AutoPublish: true
FunctionCode: !Sub "..."
FunctionConfig:
Comment: Redirects from a key value store
Runtime: cloudfront-js-2.0
KeyValueStoreAssociations:
- KeyValueStoreARN: !Ref Redirects

Fn::GetAtt supports Arn, Id and Status. Deleting the Stack deletes the store, after the Functions holding it have gone.

CDK’s cloudfront.KeyValueStore and the keyValueStore prop on cloudfront.Function both deploy. A CDK stack needs no hand-editing.

cf.kvs() refuses when the Function is associated with no store, and refuses an ID belonging to some other store. Handing back an empty store would let a Function that lost its association run to completion and quietly take every default.

Every CloudFront command goes through simulated IAM. So does every CloudFront Resource a CloudFormation Stack creates, decided as the principal the deployment runs as. Stand a project’s own execution policy up as a deploy Role and the Stack finds out what the policy leaves out. A deployment naming no principal is decided as the Account root.

The action is the cloudfront: name of the operation. A Distribution action is decided against arn:aws:cloudfront::<account>:distribution/<id>, a Function action against function/<name> and a key value store action against key-value-store/<id>. A create action has nothing to name until it succeeds and is decided against *. A policy granting one has to write the wildcard.

These are the actions the simulation asks about:

  • CreateDistribution, GetDistribution, UpdateDistribution and DeleteDistribution
  • CreateFunction, ListFunctions, DescribeFunction, GetFunction and DeleteFunction
  • CreateInvalidation, GetInvalidation and ListInvalidations
  • CreateKeyValueStore, ListKeyValueStores, DescribeKeyValueStore, UpdateKeyValueStore and DeleteKeyValueStore, alongside the data API’s own cloudfront-keyvaluestore: actions on the keys inside a store
  • CreateCachePolicy and DeleteCachePolicy
  • CreateOriginRequestPolicy and DeleteOriginRequestPolicy
  • CreateResponseHeadersPolicy and DeleteResponseHeadersPolicy
  • CreateOriginAccessControl and DeleteOriginAccessControl

The last four pairs reach IAM through CloudFormation alone, since a template is the only way to make one of those four here. Each delete is decided against the ARN of the thing it names, such as arn:aws:cloudfront::<account>:cache-policy/<id>.

/**
* A deploy Role refused the cache policy its Stack declares.
*/
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
// A deploy Role allowed CloudFormation and S3, and no CloudFront action.
const { Role } = await simAws.iam().createRole({
input: {
RoleName: "DeployRole",
AssumeRolePolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: { Service: "cloudformation.amazonaws.com" },
Action: "sts:AssumeRole",
},
],
}),
},
});
await simAws.iam().putRolePolicy({
input: {
RoleName: "DeployRole",
PolicyName: "DeployPolicy",
PolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Action: ["cloudformation:*", "s3:*"],
Resource: "*",
},
],
}),
},
});
try {
await simAws.cloudFormation().deployTemplate({
stackName: "site-stack",
caller: { kind: "arn", arn: Role.Arn },
template: {
Resources: {
SiteCachePolicy: {
Type: "AWS::CloudFront::CachePolicy",
Properties: {
CachePolicyConfig: {
Name: "site-caching",
MinTTL: 0,
ParametersInCacheKeyAndForwardedToOrigin: {
EnableAcceptEncodingGzip: false,
CookiesConfig: { CookieBehavior: "none" },
HeadersConfig: { HeaderBehavior: "none" },
QueryStringsConfig: { QueryStringBehavior: "none" },
},
},
},
},
},
},
});
} catch (error) {
// Sim CloudFormation Resource SiteCachePolicy creation failed: User:
// arn:aws:iam::...:role/DeployRole is not authorized to perform:
// cloudfront:CreateCachePolicy on resource: *
console.log((error as Error).message);
}

Sim CloudFront currently supports:

  • CreateDistributionCommand, GetDistributionCommand, UpdateDistributionCommand and DeleteDistributionCommand
  • CreateFunctionCommand, ListFunctionsCommand, DescribeFunctionCommand, GetFunctionCommand and DeleteFunctionCommand
  • Refusing Function code over CloudFront’s 10 KB size limit, with FunctionSizeLimitExceeded
  • Key value stores, through both the CloudFront client and the key value store data client
  • S3 Origins backed by sim S3 Buckets, reading them as the Bucket policy allows
  • Custom Origins reaching sim HTTP APIs and sim Lambda Function URLs in process
  • CustomHeaders and OriginCustomHeaders on an Origin, for an origin that admits only CloudFront
  • CloudFront Distribution hostnames such as distro123.cloudfront.net
  • Default cache Behavior and path-based cache Behaviors
  • DefaultRootObject and CustomErrorResponses, for static sites and single-page apps
  • viewer-request and viewer-response CloudFront Functions, including async ones
  • Lambda@Edge functions at all four events, through LambdaFunctionAssociations
  • LambdaFunctionAssociations on a template’s Distribution, and CDK’s edgeLambdas
  • CloudFront Functions reading an associated key value store through cf.kvs()
  • AWS::CloudFront::ResponseHeadersPolicy, for headers a cache Behavior sets on every response
  • AWS::CloudFront::CachePolicy, and a Behavior’s CachePolicyId read back through GetDistribution
  • A cache on each Distribution, keyed on the Behavior’s cache policy and on the edge the request arrived at, expiring on the simulation’s clock
  • CreateInvalidationCommand, GetInvalidationCommand and ListInvalidationsCommand, clearing what a Distribution holds and reading the batches back
  • AWS::CloudFront::OriginRequestPolicy, and a Behavior’s OriginRequestPolicyId read back the same way
  • AWS::CloudFront::KeyValueStore, and KeyValueStoreAssociations on AWS::CloudFront::Function
  • AWS::CloudFront::OriginAccessControl, letting an Origin read a private Bucket as CloudFront
  • Viewer certificates from sim ACM, including CloudFront’s us-east-1 requirement
  • WebACLId, putting a simulated WAFv2 web ACL in front of everything a Distribution serves
  • Serving simulated CloudFront traffic on localhost with serveSimAws

The simulator focuses on useful behaviour for tests and local development, ahead of full CloudFront feature parity. Unsupported CloudFront options may be ignored or may throw errors depending on whether the simulator needs them to model the requested behaviour safely.

Where sim CloudFront knowingly behaves differently from AWS:

  • An expired entry is fetched again rather than revalidated. Real CloudFront asks the Origin whether the object has changed and takes a 304 Not Modified as permission to carry on serving what it holds. Here the expired entry is dropped and the Origin’s next answer replaces it. Stale-While-Revalidate and Stale-If-Error are read as no directive at all. Nothing stale is ever served.
  • A cached entry stays until it expires or an invalidation clears it. Nothing evicts one to make room.
  • X-Cache says Hit or Miss and nothing else. Real CloudFront also reports RefreshHit, Error, Redirect, LambdaGeneratedResponse and FunctionGeneratedResponse there. A response answered before the cache was reached (one a web ACL blocked, or one a viewer-request function returned) carries no X-Cache at all.
  • An invalidation listing is never paged. ListInvalidations echoes the Marker and MaxItems it was sent and answers with every invalidation the Distribution holds. IsTruncated is always false and no NextMarker comes back. Real CloudFront pages at 100.
  • A Behavior with no cache policy caches nothing. Real CloudFront falls back to the legacy ForwardedValues and the TTLs beside it, and sim CloudFront skips both. Give the Behavior a CachePolicyId, as CDK and the console both do.
  • Every error status is held for its error TTL. Real CloudFront caches 404, 414, 500, 501, 502, 503 and 504, and caches 400, 403 and 405 only where the Origin sent a Cache-Control max-age or s-maxage header. It then holds the error for the longer of that header and ErrorCachingMinTTL. Here every status of 400 and above is held for ErrorCachingMinTTL alone.
  • An Origin keeps its kind and its Bucket through an origin-request function. Real CloudFront lets a handler hand back origin.s3 where it was given origin.custom, or point an S3 Origin at another Bucket. Both need something a simulated Origin does not hold, the dispatcher that reaches a custom Origin and the Bucket a domain name resolved to when the Distribution was written. Each is refused with the 502 a failed edge function gets, carrying the reason. The domain name, the Origin path and the custom headers are the parts a handler can rewrite.
  • A custom Origin reports CloudFront’s default connection settings. keepaliveTimeout, port, protocol, readTimeout and sslProtocols are what an origin event carries for every custom Origin, whatever CustomOriginConfig said, and a handler writing them changes nothing about the fetch. Nothing here opens a socket for them to apply to. The customHeaders of an S3 Origin are empty for the same kind of reason. An S3 Origin reads its Bucket through GetObject and builds no request for a header to travel on.
  • A custom error page is fetched without the origin events. CloudFront fetches ResponsePagePath from the Origin, and an origin function runs for that fetch as it does for any other. Here the page is fetched directly. An origin-request function that rewrote the Origin leaves the error page coming from the Behavior’s own Origin.
  • A CDK EdgeFunction outside us-east-1 wants the whole cloud assembly. cloudfront.experimental.EdgeFunction writes the function into a us-east-1 support stack and reads its ARN back through a custom resource in the stack that uses it. deployCdkOut deploys both stacks and the read finds the ARN. deployTemplateFile on the using stack’s template alone deploys one of them, the read finds nothing, and the Distribution goes up without the association, recorded on stack.ignoredProperties. cdk deploy deploys both either way.
  • Nothing is replicated. Real Lambda@Edge copies the function out to every Region and creates the AWSServiceRoleForLambdaReplicator service-linked role to do it. Here the function is invoked where it was created. The trust policy and the lambda:GetFunction and lambda:EnableReplication permissions a real association needs are still checked, because those are what a first deploy fails on.
  • A Lambda@Edge body is never truncated. CloudFront caps the body it sends a viewer-request function and reports inputTruncated when it had to cut one. Every simulated body arrives whole and inputTruncated is always false, so a test finds out nothing about whether its request would be too large for a real edge function.
  • The Origin’s status decides whether a viewer-response function runs. CloudFront skips the viewer-response event once the Origin answers 400 or higher, and both kinds of function are skipped here on that rule. Where the status is replaced further down the pipeline, by a custom error response or by an origin-response function, the Origin’s own status still decides. AWS documents the restriction against the Origin’s status and says nothing about the status something else puts in its place. A Distribution combining the two is where this simulation is guessing. A response an origin-request function generated has no Origin status behind it, and its own status stands in.
  • CloudFront’s disallowed and read-only header lists go unchecked. Real CloudFront answers 502 when an edge function adds Connection or edits Content-Length. Yulin accepts those changes. It restores the host after a viewer-request function.
  • An S3 Origin with no origin access control reads its Bucket anonymously. That is the unsigned request real CloudFront sends to the S3 REST endpoint without one. The Bucket policy has to make an Object publicly readable for the Distribution to serve it. A legacy S3OriginConfig.OriginAccessIdentity is refused by name. It signs the Origin request as a CloudFront canonical user nothing here models, and a Bucket policy written for one would deny the read in silence.
  • A signed Origin request carries no signature. An Origin whose origin access control signs reaches the Origin as the cloudfront.amazonaws.com service principal carrying the Distribution’s ARN. That pair is what the Bucket policy or the function’s resource policy is evaluated against, and no SigV4 signature is computed or checked. A Function URL Origin is told who the request is from at the simulated HTTP boundary, the same way anything else calling into simulated AWS in process says who it is. No other simulated request is signed here either, and the signature itself is beyond what a test can assert on. The payload hash is the one part of a signature that is stated and checked, because a Function URL turns a POST away over it. See posting to a Function URL Origin.
  • An origin access control signs for an S3 or Lambda Function URL Origin only. CloudFront also signs for MediaStore and MediaPackage V2 Origins, and both are left out. An OriginAccessControlOriginType other than s3 or lambda, or a SigningProtocol other than sigv4, fails the Stack by naming the value. Neither is quietly treated as one of the two.
  • An origin access control name is unique, and that is the whole of the checking. A second one claiming a name is refused with OriginAccessControlAlreadyExists, as CloudFront refuses one.
  • An origin access control has no command surface. CreateOriginAccessControl and its siblings are absent, and AWS::CloudFront::OriginAccessControl is the only way to make one.
  • A list’s Quantity is only checked when it is there. Every CloudFront list carries a count alongside its items, and a Quantity that disagrees with Items is refused with InconsistentQuantities, as CloudFront refuses it. CloudFormation uses a plain array without a count, so templates skip this validation. A hand-written { Items: [...] } also skips it when the count is absent. The AWS SDK types make omitting Quantity a compile error, so what arrives without one is a different mistake from the one this catches.
  • A web ACL a Distribution names has to exist here. WebACLId resolves to a web ACL created in this simulation, and the ARN carries the Account and Region holding it. A managed web ACL, or one from a real account, is refused at create and at update. A CloudFormation Distribution is the exception and deploys without it, recording the property. Deleting a web ACL a Distribution still names leaves the Distribution answering InvalidWebACLId on every request, because real WAF refuses that deletion and nothing here tracks the association to refuse it.
  • IfMatch ETags are ignored on a Distribution or a Function. UpdateDistributionCommand, DeleteDistributionCommand and DeleteFunctionCommand all accept IfMatch and ignore it, leaving both PreconditionFailed and InvalidIfMatchVersion unused there. A stale ETag there costs a retry. The key value store commands are the exception and do check it, because the data API is built around it, and two writers racing on one store is the case it exists to catch.
  • A key value store has no size quota. CloudFront caps a store’s total size and the length of a single key and value, and refuses a write that would exceed either. Nothing here counts against a quota, and TotalSizeInBytes is reported without being enforced. A test can find out nothing about whether its data would be too large for a real store.
  • A key value store association is fixed once the Function is created. There is no UpdateFunction here, and the store a Function reads is the one it was created with. Delete the Function and create it again to change it.
  • A bound handler goes unmeasured. Function code over CloudFront’s 10 KB limit is refused with FunctionSizeLimitExceeded, counted on the source as uploaded. A handler passed as a function reference, through makeCffFunctionCodeInput or a CloudFormation binding, carries no source to count. The limit reaches only the inline code a real deploy would upload.
  • ImportSource is unsupported. CreateKeyValueStoreCommand ignores it, and AWS::CloudFront::KeyValueStore refuses a Resource carrying one. Nothing here reads an S3 Object as key data, and deploying an empty store would let a test pass against data the deploy should have seeded. Write the keys with PutKey or UpdateKeys.
  • A Status Output holds the status at deploy time. CloudFormation Outputs are resolved once, while a new store is still PROVISIONING, so Fn::GetAtt on Status in an Output reads PROVISIONING even though the store goes on to become READY. Read the store itself for its current status.
  • Key listing is unpaginated. ListKeysCommand and ListKeyValueStoresCommand answer with everything and never set a NextToken or NextMarker, leaving a test with no paging loop to exercise.
  • A deletion goes ahead without waiting for the disable to deploy. Real CloudFront needs the disabled Distribution to reach Deployed before it accepts the deletion. Here, Enabled: false is enough.
  • A disabled Distribution still serves requests. Real CloudFront answers a disabled Distribution with a 403. Only deleting a Distribution stops it serving here.
  • DeleteFunctionCommand never answers FunctionInUse. A CloudFront Function is never told that a cache Behavior has taken it up, and every Function is deletable. A Behavior left pointing at a deleted Function runs no Function code.
  • A response headers policy name is unique, and that is the whole of the checking. A second policy claiming a name is refused with ResponseHeadersPolicyAlreadyExists, as CloudFront refuses one. The header names and values themselves are stored as written.
  • A response headers policy has no command surface. CreateResponseHeadersPolicy and its siblings are absent, and AWS::CloudFront::ResponseHeadersPolicy is the only way to make one.
  • ServerTimingHeadersConfig always adds the header once enabled. SamplingRate decides what share of real responses carry Server-Timing. This simulation adds it to every response once Enabled is true. A test asserting on it never depends on chance. The header’s value is a fixed placeholder, since nothing here measures an Origin fetch the way CloudFront’s edge does.
  • Origin requests use the Origin’s Host header. Real CloudFront can forward the viewer’s value when a policy includes it. Yulin always sends the Origin domain because the request must reach the simulated service named by the URL.
  • CloudFront-generated headers are absent. Yulin omits X-Amz-Cf-Id, Via, X-Forwarded-For and the CloudFront-Viewer-* family. It generates Host, User-Agent and a normalized Accept-Encoding value.