From 83a676bbb658d4ac22f431dbe58d96606d2a2bec Mon Sep 17 00:00:00 2001 From: Jonathan Goldwasser Date: Tue, 6 Jan 2026 11:36:14 +0100 Subject: [PATCH 1/5] feat(static-website): use CloudFront functions for rewrite and redirect --- src/static-website/index.ts | 77 ++- src/static-website/origin-request-function.ts | 26 - .../origin-request.edge-lambda.ts | 9 - src/url-shortener/index.ts | 2 +- .../__snapshots__/static-website.test.ts.snap | 607 +++--------------- .../origin-request-handler.test.ts | 33 - 6 files changed, 151 insertions(+), 603 deletions(-) delete mode 100644 src/static-website/origin-request-function.ts delete mode 100644 src/static-website/origin-request.edge-lambda.ts delete mode 100644 test/static-website/origin-request-handler.test.ts diff --git a/src/static-website/index.ts b/src/static-website/index.ts index 883d1667..97fe5c4c 100644 --- a/src/static-website/index.ts +++ b/src/static-website/index.ts @@ -4,12 +4,10 @@ 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 route53 from 'aws-cdk-lib/aws-route53'; -import * as patterns from 'aws-cdk-lib/aws-route53-patterns'; import * as targets from 'aws-cdk-lib/aws-route53-targets'; import * as s3 from 'aws-cdk-lib/aws-s3'; import * as cr from 'aws-cdk-lib/custom-resources'; import { Construct } from 'constructs'; -import { OriginRequestFunction } from './origin-request-function'; /** * Properties for a StaticWebsite @@ -125,12 +123,13 @@ export class StaticWebsite extends Construct { defaultBehavior: { origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket), viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, - edgeLambdas: props.edgeLambdas ?? [ - { - eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST, - functionVersion: new OriginRequestFunction(this, 'OriginRequest'), - }, - ], + edgeLambdas: props.edgeLambdas, + functionAssociations: [{ + eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, + function: new cloudfront.Function(this, 'RewriteFunction', { + code: cloudfront.FunctionCode.fromInline(rewriteFunctionCode()), + }), + }], responseHeadersPolicy: props.responseHeadersPolicy ?? new cloudfront.ResponseHeadersPolicy(this, 'ResponseHeadersPolicy', { securityHeadersBehavior: StaticWebsite.defaultSecurityHeadersBehavior, }), @@ -156,7 +155,7 @@ export class StaticWebsite extends Construct { }); new route53.RecordSet(this, 'HttpsRecord', { - recordType: 'HTTPS' as route53.RecordType, + recordType: route53.RecordType.HTTPS, recordName: props.domainName, zone: props.hostedZone, target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(this.distribution)), @@ -187,17 +186,32 @@ export class StaticWebsite extends Construct { } if (shouldAddRedirect(props)) { - const httpsRedirect = new patterns.HttpsRedirect(this, 'HttpsRedirect', { - targetDomain: props.domainName, - zone: props.hostedZone, - recordNames: props.redirects, + const redirectDistribution = new cloudfront.Distribution(this, 'RedirectDistribution', { + defaultBehavior: { + origin: new origins.HttpOrigin(props.domainName), + viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + functionAssociations: [{ + eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, + function: new cloudfront.Function(this, 'RedirectFunction', { + code: cloudfront.FunctionCode.fromInline(redirectFunctionCode(props.domainName)), + }), + }], + }, + defaultRootObject: '', + domainNames: props.redirects, + certificate: props.certificate, + httpVersion: cloudfront.HttpVersion.HTTP2_AND_3, + comment: `Redirect to ${props.domainName} from ${props.redirects?.join(', ')}`, }); - // Force minimum protocol version - const redirectDistribution = httpsRedirect.node.tryFindChild('RedirectDistribution') as cloudfront.CloudFrontWebDistribution; - const cfnDistribution = redirectDistribution.node.tryFindChild('CFDistribution') as cloudfront.CfnDistribution; - if (cfnDistribution) { - cfnDistribution.addPropertyOverride('DistributionConfig.ViewerCertificate.MinimumProtocolVersion', 'TLSv1.2_2021'); - cfnDistribution.addPropertyOverride('DistributionConfig.HttpVersion', 'http2and3'); + for (const redirect of props.redirects ?? []) { + const aliasProps = { + recordName: redirect, + zone: props.hostedZone, + target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(redirectDistribution)), + }; + new route53.ARecord(this, `RedirectARecord${redirect}`, aliasProps); + new route53.AaaaRecord(this, `RedirectAaaaRecord${redirect}`, aliasProps); + new route53.HttpsRecord(this, `RedirectHttpsRecord${redirect}`, aliasProps); } } } @@ -216,3 +230,28 @@ function shouldAddRedirect(props: StaticWebsiteProps): boolean { return true; } + +function rewriteFunctionCode(): string { + return `function handler(event) { + const request = event.request; + const uri = request.uri; + const hasExtension = /\.[a-zA-Z0-9]+$/.test(uri); + if (!hasExtension) { + request.uri = '/index.html'; + } + return request; + }`; +} + +function redirectFunctionCode(domainName: string): string { + return `function handler(event) { + return { + statusCode: 301, + statusDescription: 'Moved permanently', + headers: { + location: { + value: 'https://${domainName}', + }, + }, + };`; +} diff --git a/src/static-website/origin-request-function.ts b/src/static-website/origin-request-function.ts deleted file mode 100644 index 13412846..00000000 --- a/src/static-website/origin-request-function.ts +++ /dev/null @@ -1,26 +0,0 @@ -// ~~ Generated by projen. To modify, edit .projenrc.ts and run "npx projen". -import * as path from 'path'; -import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; -import * as lambda from 'aws-cdk-lib/aws-lambda'; -import { Construct } from 'constructs'; - -/** - * Props for OriginRequestFunction - */ -export interface OriginRequestFunctionProps extends cloudfront.experimental.EdgeFunctionProps { -} - -/** - * An AWS Lambda function which executes src/static-website/origin-request. - */ -export class OriginRequestFunction extends cloudfront.experimental.EdgeFunction { - constructor(scope: Construct, id: string, props?: OriginRequestFunctionProps) { - super(scope, id, { - description: 'src/static-website/origin-request.edge-lambda.ts', - ...props, - runtime: new lambda.Runtime('nodejs24.x', lambda.RuntimeFamily.NODEJS), - handler: 'index.handler', - code: lambda.Code.fromAsset(path.join(__dirname, '../../assets/static-website/origin-request.edge-lambda')), - }); - } -} \ No newline at end of file diff --git a/src/static-website/origin-request.edge-lambda.ts b/src/static-website/origin-request.edge-lambda.ts deleted file mode 100644 index d8d7805e..00000000 --- a/src/static-website/origin-request.edge-lambda.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as path from 'path'; - -export async function handler(event: AWSLambda.CloudFrontRequestEvent): Promise { - const request = event.Records[0].cf.request; - - if (!path.extname(request.uri)) request.uri = '/index.html'; - - return request; -} diff --git a/src/url-shortener/index.ts b/src/url-shortener/index.ts index b2d8b875..e5bdb0de 100644 --- a/src/url-shortener/index.ts +++ b/src/url-shortener/index.ts @@ -138,7 +138,7 @@ export class UrlShortener extends Construct { }, certificate: props.certificate, domainNames: [domainName], - httpVersion: 'http2and3' as cloudfront.HttpVersion, + httpVersion: cloudfront.HttpVersion.HTTP2_AND_3, }); // Route53 records diff --git a/test/static-website/__snapshots__/static-website.test.ts.snap b/test/static-website/__snapshots__/static-website.test.ts.snap index 3f755b08..1c0043b8 100644 --- a/test/static-website/__snapshots__/static-website.test.ts.snap +++ b/test/static-website/__snapshots__/static-website.test.ts.snap @@ -1,129 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`StaticWebsite 1`] = ` -{ - "Parameters": { - "BootstrapVersion": { - "Default": "/cdk-bootstrap/hnb659fds/version", - "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]", - "Type": "AWS::SSM::Parameter::Value", - }, - }, - "Resources": { - "OriginRequest0052232D": { - "DependsOn": [ - "OriginRequestServiceRole04760C2E", - ], - "Properties": { - "Code": { - "S3Bucket": { - "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-us-east-1", - }, - "S3Key": "26f41d5aecce2a35015d531a35df7ba8056a4dd03df5aedc8b706fb6d5d7a0d8.zip", - }, - "Description": "src/static-website/origin-request.edge-lambda.ts", - "Handler": "index.handler", - "Role": { - "Fn::GetAtt": [ - "OriginRequestServiceRole04760C2E", - "Arn", - ], - }, - "Runtime": "nodejs24.x", - }, - "Type": "AWS::Lambda::Function", - }, - "OriginRequestCurrentVersionD6F77E9F1aa1d0332e3d0c3ddd4e96ca4a785f29": { - "Metadata": { - "aws:cdk:do-not-refactor": true, - }, - "Properties": { - "FunctionName": { - "Ref": "OriginRequest0052232D", - }, - }, - "Type": "AWS::Lambda::Version", - }, - "OriginRequestParameter759D9B4C": { - "Properties": { - "Name": "/cdk/EdgeFunctionArn/eu-west-1/Stack/StaticWebsite/OriginRequest", - "Type": "String", - "Value": { - "Ref": "OriginRequestCurrentVersionD6F77E9F1aa1d0332e3d0c3ddd4e96ca4a785f29", - }, - }, - "Type": "AWS::SSM::Parameter", - }, - "OriginRequestServiceRole04760C2E": { - "Properties": { - "AssumeRolePolicyDocument": { - "Statement": [ - { - "Action": "sts:AssumeRole", - "Effect": "Allow", - "Principal": { - "Service": "lambda.amazonaws.com", - }, - }, - { - "Action": "sts:AssumeRole", - "Effect": "Allow", - "Principal": { - "Service": "edgelambda.amazonaws.com", - }, - }, - ], - "Version": "2012-10-17", - }, - "ManagedPolicyArns": [ - { - "Fn::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition", - }, - ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", - ], - ], - }, - ], - }, - "Type": "AWS::IAM::Role", - }, - }, - "Rules": { - "CheckBootstrapVersion": { - "Assertions": [ - { - "Assert": { - "Fn::Not": [ - { - "Fn::Contains": [ - [ - "1", - "2", - "3", - "4", - "5", - ], - { - "Ref": "BootstrapVersion", - }, - ], - }, - ], - }, - "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI.", - }, - ], - }, - }, -} -`; - -exports[`StaticWebsite 2`] = ` { "Mappings": { "AWSCloudFrontPartitionHostedZoneIdMap": { @@ -284,84 +161,6 @@ exports[`StaticWebsite 2`] = ` }, "Type": "AWS::ApiGatewayV2::Api", }, - "CustomCrossRegionStringParameterReaderCustomResourceProviderHandler65B5F33A": { - "DependsOn": [ - "CustomCrossRegionStringParameterReaderCustomResourceProviderRole71CD6825", - ], - "Properties": { - "Code": { - "S3Bucket": { - "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-eu-west-1", - }, - "S3Key": "934f26e3be418ca933eeffc0c28bc2546c67037b7ea94c28b93ad93e1952a30e.zip", - }, - "Handler": "__entrypoint__.handler", - "MemorySize": 128, - "Role": { - "Fn::GetAtt": [ - "CustomCrossRegionStringParameterReaderCustomResourceProviderRole71CD6825", - "Arn", - ], - }, - "Runtime": "nodejs22.x", - "Timeout": 900, - }, - "Type": "AWS::Lambda::Function", - }, - "CustomCrossRegionStringParameterReaderCustomResourceProviderRole71CD6825": { - "Properties": { - "AssumeRolePolicyDocument": { - "Statement": [ - { - "Action": "sts:AssumeRole", - "Effect": "Allow", - "Principal": { - "Service": "lambda.amazonaws.com", - }, - }, - ], - "Version": "2012-10-17", - }, - "ManagedPolicyArns": [ - { - "Fn::Sub": "arn:\${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", - }, - ], - "Policies": [ - { - "PolicyDocument": { - "Statement": [ - { - "Action": [ - "ssm:GetParameter", - ], - "Effect": "Allow", - "Resource": { - "Fn::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition", - }, - ":ssm:us-east-1:", - { - "Ref": "AWS::AccountId", - }, - ":parameter/cdk/EdgeFunctionArn/*", - ], - ], - }, - }, - ], - "Version": "2012-10-17", - }, - "PolicyName": "Inline", - }, - ], - }, - "Type": "AWS::IAM::Role", - }, "Fn9270CBC0": { "DependsOn": [ "FnServiceRoleB9001A96", @@ -551,13 +350,13 @@ exports[`StaticWebsite 2`] = ` "DefaultCacheBehavior": { "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6", "Compress": true, - "LambdaFunctionAssociations": [ + "FunctionAssociations": [ { - "EventType": "origin-request", - "LambdaFunctionARN": { + "EventType": "viewer-request", + "FunctionARN": { "Fn::GetAtt": [ - "StaticWebsiteOriginRequestArnReaderEA9004DC", - "FunctionArn", + "StaticWebsiteRewriteFunction32E38FCF", + "FunctionARN", ], }, }, @@ -639,315 +438,6 @@ exports[`StaticWebsite 2`] = ` }, "Type": "AWS::Route53::RecordSet", }, - "StaticWebsiteHttpsRedirectRedirectAliasRecord2591f3E6FEEF4C": { - "Properties": { - "AliasTarget": { - "DNSName": { - "Fn::GetAtt": [ - "StaticWebsiteHttpsRedirectRedirectDistributionCFDistributionF7ADE06F", - "DomainName", - ], - }, - "HostedZoneId": { - "Fn::FindInMap": [ - "AWSCloudFrontPartitionHostedZoneIdMap", - { - "Ref": "AWS::Partition", - }, - "zoneId", - ], - }, - }, - "HostedZoneId": { - "Ref": "HostedZoneDB99F866", - }, - "Name": "my-site.com.", - "Type": "A", - }, - "Type": "AWS::Route53::RecordSet", - }, - "StaticWebsiteHttpsRedirectRedirectAliasRecordSix2591f309F9321D": { - "Properties": { - "AliasTarget": { - "DNSName": { - "Fn::GetAtt": [ - "StaticWebsiteHttpsRedirectRedirectDistributionCFDistributionF7ADE06F", - "DomainName", - ], - }, - "HostedZoneId": { - "Fn::FindInMap": [ - "AWSCloudFrontPartitionHostedZoneIdMap", - { - "Ref": "AWS::Partition", - }, - "zoneId", - ], - }, - }, - "HostedZoneId": { - "Ref": "HostedZoneDB99F866", - }, - "Name": "my-site.com.", - "Type": "AAAA", - }, - "Type": "AWS::Route53::RecordSet", - }, - "StaticWebsiteHttpsRedirectRedirectBucket45BA11C9": { - "DeletionPolicy": "Delete", - "Properties": { - "PublicAccessBlockConfiguration": { - "BlockPublicAcls": true, - "BlockPublicPolicy": true, - "IgnorePublicAcls": true, - "RestrictPublicBuckets": true, - }, - "WebsiteConfiguration": { - "RedirectAllRequestsTo": { - "HostName": "www.my-site.com", - "Protocol": "https", - }, - }, - }, - "Type": "AWS::S3::Bucket", - "UpdateReplacePolicy": "Delete", - }, - "StaticWebsiteHttpsRedirectRedirectCertificateCertificateRequestorFunctionEE7DB513": { - "DependsOn": [ - "StaticWebsiteHttpsRedirectRedirectCertificateCertificateRequestorFunctionServiceRoleDefaultPolicy81FC4515", - "StaticWebsiteHttpsRedirectRedirectCertificateCertificateRequestorFunctionServiceRole08DAF404", - ], - "Properties": { - "Code": { - "S3Bucket": { - "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-eu-west-1", - }, - "S3Key": "b073cebcf4d61fb152a30f5a5e57a94df7f980a549fdf1a79a0b18c5750522d8.zip", - }, - "Handler": "index.certificateRequestHandler", - "Role": { - "Fn::GetAtt": [ - "StaticWebsiteHttpsRedirectRedirectCertificateCertificateRequestorFunctionServiceRole08DAF404", - "Arn", - ], - }, - "Runtime": "nodejs22.x", - "Timeout": 900, - }, - "Type": "AWS::Lambda::Function", - }, - "StaticWebsiteHttpsRedirectRedirectCertificateCertificateRequestorFunctionServiceRole08DAF404": { - "Properties": { - "AssumeRolePolicyDocument": { - "Statement": [ - { - "Action": "sts:AssumeRole", - "Effect": "Allow", - "Principal": { - "Service": "lambda.amazonaws.com", - }, - }, - ], - "Version": "2012-10-17", - }, - "ManagedPolicyArns": [ - { - "Fn::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition", - }, - ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", - ], - ], - }, - ], - }, - "Type": "AWS::IAM::Role", - }, - "StaticWebsiteHttpsRedirectRedirectCertificateCertificateRequestorFunctionServiceRoleDefaultPolicy81FC4515": { - "Properties": { - "PolicyDocument": { - "Statement": [ - { - "Action": [ - "acm:RequestCertificate", - "acm:DescribeCertificate", - "acm:DeleteCertificate", - "acm:AddTagsToCertificate", - ], - "Effect": "Allow", - "Resource": "*", - }, - { - "Action": "route53:GetChange", - "Effect": "Allow", - "Resource": "*", - }, - { - "Action": "route53:changeResourceRecordSets", - "Condition": { - "ForAllValues:StringEquals": { - "route53:ChangeResourceRecordSetsActions": [ - "UPSERT", - ], - "route53:ChangeResourceRecordSetsRecordTypes": [ - "CNAME", - ], - }, - "ForAllValues:StringLike": { - "route53:ChangeResourceRecordSetsNormalizedRecordNames": [ - "*.my-site.com", - "*.my-site.com", - ], - }, - }, - "Effect": "Allow", - "Resource": { - "Fn::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition", - }, - ":route53:::hostedzone/", - { - "Ref": "HostedZoneDB99F866", - }, - ], - ], - }, - }, - ], - "Version": "2012-10-17", - }, - "PolicyName": "StaticWebsiteHttpsRedirectRedirectCertificateCertificateRequestorFunctionServiceRoleDefaultPolicy81FC4515", - "Roles": [ - { - "Ref": "StaticWebsiteHttpsRedirectRedirectCertificateCertificateRequestorFunctionServiceRole08DAF404", - }, - ], - }, - "Type": "AWS::IAM::Policy", - }, - "StaticWebsiteHttpsRedirectRedirectCertificateCertificateRequestorResource692E38D9": { - "DeletionPolicy": "Delete", - "Properties": { - "DomainName": "my-site.com", - "HostedZoneId": { - "Ref": "HostedZoneDB99F866", - }, - "Region": "us-east-1", - "ServiceToken": { - "Fn::GetAtt": [ - "StaticWebsiteHttpsRedirectRedirectCertificateCertificateRequestorFunctionEE7DB513", - "Arn", - ], - }, - "SubjectAlternativeNames": [ - "my-site.com", - ], - }, - "Type": "AWS::CloudFormation::CustomResource", - "UpdateReplacePolicy": "Delete", - }, - "StaticWebsiteHttpsRedirectRedirectDistributionCFDistributionF7ADE06F": { - "Properties": { - "DistributionConfig": { - "Aliases": [ - "my-site.com", - ], - "Comment": "Redirect to www.my-site.com from my-site.com", - "DefaultCacheBehavior": { - "AllowedMethods": [ - "GET", - "HEAD", - ], - "CachedMethods": [ - "GET", - "HEAD", - ], - "Compress": true, - "ForwardedValues": { - "Cookies": { - "Forward": "none", - }, - "QueryString": false, - }, - "TargetOriginId": "origin1", - "ViewerProtocolPolicy": "redirect-to-https", - }, - "DefaultRootObject": "", - "Enabled": true, - "HttpVersion": "http2and3", - "IPV6Enabled": true, - "Origins": [ - { - "ConnectionAttempts": 3, - "ConnectionTimeout": 10, - "CustomOriginConfig": { - "HTTPPort": 80, - "HTTPSPort": 443, - "OriginKeepaliveTimeout": 5, - "OriginProtocolPolicy": "http-only", - "OriginReadTimeout": 30, - "OriginSSLProtocols": [ - "TLSv1.2", - ], - }, - "DomainName": { - "Fn::Select": [ - 2, - { - "Fn::Split": [ - "/", - { - "Fn::GetAtt": [ - "StaticWebsiteHttpsRedirectRedirectBucket45BA11C9", - "WebsiteURL", - ], - }, - ], - }, - ], - }, - "Id": "origin1", - }, - ], - "PriceClass": "PriceClass_All", - "ViewerCertificate": { - "AcmCertificateArn": { - "Fn::GetAtt": [ - "StaticWebsiteHttpsRedirectRedirectCertificateCertificateRequestorResource692E38D9", - "Arn", - ], - }, - "MinimumProtocolVersion": "TLSv1.2_2021", - "SslSupportMethod": "sni-only", - }, - }, - }, - "Type": "AWS::CloudFront::Distribution", - }, - "StaticWebsiteOriginRequestArnReaderEA9004DC": { - "DeletionPolicy": "Delete", - "Properties": { - "ParameterName": "/cdk/EdgeFunctionArn/eu-west-1/Stack/StaticWebsite/OriginRequest", - "RefreshToken": "OriginRequestCurrentVersionD6F77E9F1aa1d0332e3d0c3ddd4e96ca4a785f29", - "Region": "us-east-1", - "ServiceToken": { - "Fn::GetAtt": [ - "CustomCrossRegionStringParameterReaderCustomResourceProviderHandler65B5F33A", - "Arn", - ], - }, - }, - "Type": "Custom::CrossRegionStringParameterReader", - "UpdateReplacePolicy": "Delete", - }, "StaticWebsitePutConfig8F8DD69A": { "DeletionPolicy": "Delete", "DependsOn": [ @@ -1039,6 +529,73 @@ exports[`StaticWebsite 2`] = ` }, "Type": "AWS::IAM::Policy", }, + "StaticWebsiteRedirectDistributionA10E29FF": { + "Properties": { + "DistributionConfig": { + "Comment": "Redirect to www.my-site.com from undefined", + "DefaultCacheBehavior": { + "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6", + "Compress": true, + "FunctionAssociations": [ + { + "EventType": "viewer-request", + "FunctionARN": { + "Fn::GetAtt": [ + "StaticWebsiteRedirectFunctionA4B8165C", + "FunctionARN", + ], + }, + }, + ], + "TargetOriginId": "StackStaticWebsiteRedirectDistributionOrigin157F27077", + "ViewerProtocolPolicy": "redirect-to-https", + }, + "DefaultRootObject": "", + "Enabled": true, + "HttpVersion": "http2and3", + "IPV6Enabled": true, + "Origins": [ + { + "CustomOriginConfig": { + "OriginProtocolPolicy": "https-only", + "OriginSSLProtocols": [ + "TLSv1.2", + ], + }, + "DomainName": "www.my-site.com", + "Id": "StackStaticWebsiteRedirectDistributionOrigin157F27077", + }, + ], + "ViewerCertificate": { + "AcmCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/abcd-efgh-ijkl-mnop", + "MinimumProtocolVersion": "TLSv1.2_2021", + "SslSupportMethod": "sni-only", + }, + }, + }, + "Type": "AWS::CloudFront::Distribution", + }, + "StaticWebsiteRedirectFunctionA4B8165C": { + "Properties": { + "AutoPublish": true, + "FunctionCode": "function handler(event) { + return { + statusCode: 301, + statusDescription: 'Moved permanently', + headers: { + location: { + value: 'https://www.my-site.com', + }, + }, + };", + "FunctionConfig": { + "Comment": "eu-west-1StackStaticWebsiRedirectFunction172BEC3F", + "Runtime": "cloudfront-js-1.0", + }, + "Name": "eu-west-1StackStaticWebsiRedirectFunction172BEC3F", + }, + "Type": "AWS::CloudFront::Function", + }, "StaticWebsiteResponseHeadersPolicyF3EBE566": { "Properties": { "ResponseHeadersPolicyConfig": { @@ -1071,6 +628,26 @@ exports[`StaticWebsite 2`] = ` }, "Type": "AWS::CloudFront::ResponseHeadersPolicy", }, + "StaticWebsiteRewriteFunction32E38FCF": { + "Properties": { + "AutoPublish": true, + "FunctionCode": "function handler(event) { + const request = event.request; + const uri = request.uri; + const hasExtension = /.[a-zA-Z0-9]+$/.test(uri); + if (!hasExtension) { + request.uri = '/index.html'; + } + return request; + }", + "FunctionConfig": { + "Comment": "eu-west-1StackStaticWebsiteRewriteFunction4EBA86F3", + "Runtime": "cloudfront-js-1.0", + }, + "Name": "eu-west-1StackStaticWebsiteRewriteFunction4EBA86F3", + }, + "Type": "AWS::CloudFront::Function", + }, }, "Rules": { "CheckBootstrapVersion": { diff --git a/test/static-website/origin-request-handler.test.ts b/test/static-website/origin-request-handler.test.ts deleted file mode 100644 index 033addbd..00000000 --- a/test/static-website/origin-request-handler.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { handler } from '../../src/static-website/origin-request.edge-lambda'; - -test('without extension', async () => { - const request = await handler({ - Records: [ - { - cf: { - request: { - uri: '/my-uri', - }, - }, - }, - ], - } as AWSLambda.CloudFrontRequestEvent); - - expect(request.uri).toBe('/index.html'); -}); - -test('with extension', async () => { - const request = await handler({ - Records: [ - { - cf: { - request: { - uri: '/style/cool.css', - }, - }, - }, - ], - } as AWSLambda.CloudFrontRequestEvent); - - expect(request.uri).toBe('/style/cool.css'); -}); From 61aab102b9a50a2dd41ceadcbc31e03701cc23f0 Mon Sep 17 00:00:00 2001 From: Jonathan Goldwasser Date: Tue, 6 Jan 2026 11:39:54 +0100 Subject: [PATCH 2/5] projen --- .eslintrc.json | 1 - .gitattributes | 1 - .gitignore | 1 - .projen/files.json | 1 - .projen/tasks.json | 21 --------------------- package.json | 2 -- 6 files changed, 27 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 39c80868..387a939f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -160,7 +160,6 @@ "src/ssl-server-test/analyze.lambda.ts", "src/toolkit-cleaner/clean.lambda.ts", "src/url-shortener/shortener.lambda.ts", - "src/static-website/origin-request.edge-lambda.ts", "src/url-shortener/redirect.edge-lambda.ts", ".projenrc.ts", "projenrc/**/*.ts" diff --git a/.gitattributes b/.gitattributes index ef902dda..06749854 100644 --- a/.gitattributes +++ b/.gitattributes @@ -24,7 +24,6 @@ /src/slack-events/events-function.ts linguist-generated /src/slack-textract/detect-function.ts linguist-generated /src/ssl-server-test/analyze-function.ts linguist-generated -/src/static-website/origin-request-function.ts linguist-generated /src/toolkit-cleaner/clean-function.ts linguist-generated /src/url-shortener/redirect-function.ts linguist-generated /src/url-shortener/shortener-function.ts linguist-generated diff --git a/.gitignore b/.gitignore index ac8f17a9..51d6b9e8 100644 --- a/.gitignore +++ b/.gitignore @@ -57,7 +57,6 @@ tsconfig.json !/src/ssl-server-test/analyze-function.ts !/src/toolkit-cleaner/clean-function.ts !/src/url-shortener/shortener-function.ts -!/src/static-website/origin-request-function.ts !/src/url-shortener/redirect-function.ts test/mjml-template/.tmp test/mjml-template/mjml-template.integ.snapshot/asset.* diff --git a/.projen/files.json b/.projen/files.json index 95d8d3ea..29f4e388 100644 --- a/.projen/files.json +++ b/.projen/files.json @@ -18,7 +18,6 @@ "src/slack-events/events-function.ts", "src/slack-textract/detect-function.ts", "src/ssl-server-test/analyze-function.ts", - "src/static-website/origin-request-function.ts", "src/toolkit-cleaner/clean-function.ts", "src/url-shortener/redirect-function.ts", "src/url-shortener/shortener-function.ts", diff --git a/.projen/tasks.json b/.projen/tasks.json index f4f0b638..6a8b3577 100644 --- a/.projen/tasks.json +++ b/.projen/tasks.json @@ -67,9 +67,6 @@ { "spawn": "bundle:url-shortener/shortener.lambda" }, - { - "spawn": "bundle:static-website/origin-request.edge-lambda" - }, { "spawn": "bundle:url-shortener/redirect.edge-lambda" } @@ -165,24 +162,6 @@ } ] }, - "bundle:static-website/origin-request.edge-lambda": { - "name": "bundle:static-website/origin-request.edge-lambda", - "description": "Create a JavaScript bundle from src/static-website/origin-request.edge-lambda.ts", - "steps": [ - { - "exec": "esbuild --bundle src/static-website/origin-request.edge-lambda.ts --target=\"node24\" --platform=\"node\" --outfile=\"assets/static-website/origin-request.edge-lambda/index.js\" --tsconfig=\"tsconfig.dev.json\" --external:@aws-sdk/*" - } - ] - }, - "bundle:static-website/origin-request.edge-lambda:watch": { - "name": "bundle:static-website/origin-request.edge-lambda:watch", - "description": "Continuously update the JavaScript bundle from src/static-website/origin-request.edge-lambda.ts", - "steps": [ - { - "exec": "esbuild --bundle src/static-website/origin-request.edge-lambda.ts --target=\"node24\" --platform=\"node\" --outfile=\"assets/static-website/origin-request.edge-lambda/index.js\" --tsconfig=\"tsconfig.dev.json\" --external:@aws-sdk/* --watch" - } - ] - }, "bundle:toolkit-cleaner/clean.lambda": { "name": "bundle:toolkit-cleaner/clean.lambda", "description": "Create a JavaScript bundle from src/toolkit-cleaner/clean.lambda.ts", diff --git a/package.json b/package.json index 39f3bf3c..9e9f6cf3 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,6 @@ "bundle:slack-textract/detect.lambda:watch": "npx projen bundle:slack-textract/detect.lambda:watch", "bundle:ssl-server-test/analyze.lambda": "npx projen bundle:ssl-server-test/analyze.lambda", "bundle:ssl-server-test/analyze.lambda:watch": "npx projen bundle:ssl-server-test/analyze.lambda:watch", - "bundle:static-website/origin-request.edge-lambda": "npx projen bundle:static-website/origin-request.edge-lambda", - "bundle:static-website/origin-request.edge-lambda:watch": "npx projen bundle:static-website/origin-request.edge-lambda:watch", "bundle:toolkit-cleaner/clean.lambda": "npx projen bundle:toolkit-cleaner/clean.lambda", "bundle:toolkit-cleaner/clean.lambda:watch": "npx projen bundle:toolkit-cleaner/clean.lambda:watch", "bundle:url-shortener/redirect.edge-lambda": "npx projen bundle:url-shortener/redirect.edge-lambda", From b667c1a713d4365ad01777008637adefce1304b1 Mon Sep 17 00:00:00 2001 From: Jonathan Goldwasser Date: Tue, 6 Jan 2026 11:50:09 +0100 Subject: [PATCH 3/5] review --- API.md | 2 +- src/static-website/index.ts | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/API.md b/API.md index 89c8807e..3a4b1a6b 100644 --- a/API.md +++ b/API.md @@ -3787,7 +3787,7 @@ public readonly edgeLambdas: EdgeLambda[]; ``` - *Type:* aws-cdk-lib.aws_cloudfront.EdgeLambda[] -- *Default:* an origin request function that redirects all requests for a path to /index.html +- *Default:* no edge Lambdas The Lambda@Edge functions to invoke before serving the contents. diff --git a/src/static-website/index.ts b/src/static-website/index.ts index 97fe5c4c..19d3704f 100644 --- a/src/static-website/index.ts +++ b/src/static-website/index.ts @@ -65,7 +65,7 @@ export interface StaticWebsiteProps { /** * The Lambda@Edge functions to invoke before serving the contents. * - * @default - an origin request function that redirects all requests for a path to /index.html + * @default - no edge Lambdas */ readonly edgeLambdas?: cloudfront.EdgeLambda[]; } @@ -186,6 +186,7 @@ export class StaticWebsite extends Construct { } if (shouldAddRedirect(props)) { + const redirects = props.redirects ?? [props.hostedZone.zoneName]; const redirectDistribution = new cloudfront.Distribution(this, 'RedirectDistribution', { defaultBehavior: { origin: new origins.HttpOrigin(props.domainName), @@ -198,20 +199,21 @@ export class StaticWebsite extends Construct { }], }, defaultRootObject: '', - domainNames: props.redirects, + domainNames: redirects, certificate: props.certificate, httpVersion: cloudfront.HttpVersion.HTTP2_AND_3, - comment: `Redirect to ${props.domainName} from ${props.redirects?.join(', ')}`, + comment: `Redirect to ${props.domainName} from ${redirects.join(', ')}`, }); - for (const redirect of props.redirects ?? []) { + for (const redirect of redirects) { + const safeRedirectId = redirect.replace(/\./g, '-'); const aliasProps = { recordName: redirect, zone: props.hostedZone, target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(redirectDistribution)), }; - new route53.ARecord(this, `RedirectARecord${redirect}`, aliasProps); - new route53.AaaaRecord(this, `RedirectAaaaRecord${redirect}`, aliasProps); - new route53.HttpsRecord(this, `RedirectHttpsRecord${redirect}`, aliasProps); + new route53.ARecord(this, `RedirectARecord${safeRedirectId}`, aliasProps); + new route53.AaaaRecord(this, `RedirectAaaaRecord${safeRedirectId}`, aliasProps); + new route53.HttpsRecord(this, `RedirectHttpsRecord${safeRedirectId}`, aliasProps); } } } From 5cb7b026f348fd83d3d2208b4a396f947dbe033d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 10:53:08 +0000 Subject: [PATCH 4/5] chore: self mutation Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../__snapshots__/static-website.test.ts.snap | 86 ++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/test/static-website/__snapshots__/static-website.test.ts.snap b/test/static-website/__snapshots__/static-website.test.ts.snap index 1c0043b8..220ee977 100644 --- a/test/static-website/__snapshots__/static-website.test.ts.snap +++ b/test/static-website/__snapshots__/static-website.test.ts.snap @@ -529,10 +529,67 @@ exports[`StaticWebsite 1`] = ` }, "Type": "AWS::IAM::Policy", }, + "StaticWebsiteRedirectARecordmysitecom45D4EFA8": { + "Properties": { + "AliasTarget": { + "DNSName": { + "Fn::GetAtt": [ + "StaticWebsiteRedirectDistributionA10E29FF", + "DomainName", + ], + }, + "HostedZoneId": { + "Fn::FindInMap": [ + "AWSCloudFrontPartitionHostedZoneIdMap", + { + "Ref": "AWS::Partition", + }, + "zoneId", + ], + }, + }, + "HostedZoneId": { + "Ref": "HostedZoneDB99F866", + }, + "Name": "my-site.com.", + "Type": "A", + }, + "Type": "AWS::Route53::RecordSet", + }, + "StaticWebsiteRedirectAaaaRecordmysitecom9E9936CA": { + "Properties": { + "AliasTarget": { + "DNSName": { + "Fn::GetAtt": [ + "StaticWebsiteRedirectDistributionA10E29FF", + "DomainName", + ], + }, + "HostedZoneId": { + "Fn::FindInMap": [ + "AWSCloudFrontPartitionHostedZoneIdMap", + { + "Ref": "AWS::Partition", + }, + "zoneId", + ], + }, + }, + "HostedZoneId": { + "Ref": "HostedZoneDB99F866", + }, + "Name": "my-site.com.", + "Type": "AAAA", + }, + "Type": "AWS::Route53::RecordSet", + }, "StaticWebsiteRedirectDistributionA10E29FF": { "Properties": { "DistributionConfig": { - "Comment": "Redirect to www.my-site.com from undefined", + "Aliases": [ + "my-site.com", + ], + "Comment": "Redirect to www.my-site.com from my-site.com", "DefaultCacheBehavior": { "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6", "Compress": true, @@ -596,6 +653,33 @@ exports[`StaticWebsite 1`] = ` }, "Type": "AWS::CloudFront::Function", }, + "StaticWebsiteRedirectHttpsRecordmysitecomB25F25A5": { + "Properties": { + "AliasTarget": { + "DNSName": { + "Fn::GetAtt": [ + "StaticWebsiteRedirectDistributionA10E29FF", + "DomainName", + ], + }, + "HostedZoneId": { + "Fn::FindInMap": [ + "AWSCloudFrontPartitionHostedZoneIdMap", + { + "Ref": "AWS::Partition", + }, + "zoneId", + ], + }, + }, + "HostedZoneId": { + "Ref": "HostedZoneDB99F866", + }, + "Name": "my-site.com.", + "Type": "HTTPS", + }, + "Type": "AWS::Route53::RecordSet", + }, "StaticWebsiteResponseHeadersPolicyF3EBE566": { "Properties": { "ResponseHeadersPolicyConfig": { From 4b496b5dcd591a39e613680359605d190206ec71 Mon Sep 17 00:00:00 2001 From: Jonathan Goldwasser Date: Tue, 6 Jan 2026 11:56:24 +0100 Subject: [PATCH 5/5] runtime --- src/static-website/index.ts | 2 ++ test/static-website/__snapshots__/static-website.test.ts.snap | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/static-website/index.ts b/src/static-website/index.ts index 19d3704f..d24d9b6d 100644 --- a/src/static-website/index.ts +++ b/src/static-website/index.ts @@ -128,6 +128,7 @@ export class StaticWebsite extends Construct { eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, function: new cloudfront.Function(this, 'RewriteFunction', { code: cloudfront.FunctionCode.fromInline(rewriteFunctionCode()), + runtime: cloudfront.FunctionRuntime.JS_2_0, }), }], responseHeadersPolicy: props.responseHeadersPolicy ?? new cloudfront.ResponseHeadersPolicy(this, 'ResponseHeadersPolicy', { @@ -195,6 +196,7 @@ export class StaticWebsite extends Construct { eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, function: new cloudfront.Function(this, 'RedirectFunction', { code: cloudfront.FunctionCode.fromInline(redirectFunctionCode(props.domainName)), + runtime: cloudfront.FunctionRuntime.JS_2_0, }), }], }, diff --git a/test/static-website/__snapshots__/static-website.test.ts.snap b/test/static-website/__snapshots__/static-website.test.ts.snap index 220ee977..8a103bc4 100644 --- a/test/static-website/__snapshots__/static-website.test.ts.snap +++ b/test/static-website/__snapshots__/static-website.test.ts.snap @@ -647,7 +647,7 @@ exports[`StaticWebsite 1`] = ` };", "FunctionConfig": { "Comment": "eu-west-1StackStaticWebsiRedirectFunction172BEC3F", - "Runtime": "cloudfront-js-1.0", + "Runtime": "cloudfront-js-2.0", }, "Name": "eu-west-1StackStaticWebsiRedirectFunction172BEC3F", }, @@ -726,7 +726,7 @@ exports[`StaticWebsite 1`] = ` }", "FunctionConfig": { "Comment": "eu-west-1StackStaticWebsiteRewriteFunction4EBA86F3", - "Runtime": "cloudfront-js-1.0", + "Runtime": "cloudfront-js-2.0", }, "Name": "eu-west-1StackStaticWebsiteRewriteFunction4EBA86F3", },