diff --git a/README.md b/README.md
index 2140069..438e8f0 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,7 @@ Creates AWS resources for NextJS application if they were not created. Bundles N
| profile | string | none | AWS profile to use for credentials. If parameter is empty going to read credentials from:
process.env.AWS_ACCESS_KEY_ID and process.env.AWS_SECRET_ACCESS_KEY |
| nodejs | string | 20 | Supports nodejs v18 and v20 |
| production | boolean | false | Identifies if you want to create production AWS resources. So they are going to have different delete policies to keep data in safe. |
+| cloudFrontId | string | none | Existing cloud front distribution id. Useful for when new cloudfront distribution isn't needed |
## Architecture
diff --git a/src/cdk/constructs/CheckExpirationLambdaEdge.ts b/src/cdk/constructs/CheckExpirationLambdaEdge.ts
index 1197598..62f810d 100644
--- a/src/cdk/constructs/CheckExpirationLambdaEdge.ts
+++ b/src/cdk/constructs/CheckExpirationLambdaEdge.ts
@@ -7,6 +7,7 @@ import * as iam from 'aws-cdk-lib/aws-iam'
import path from 'node:path'
import { buildLambda } from '../../build/edge'
import { CacheConfig } from '../../types'
+import { addOutput } from '../../common/cdk'
interface CheckExpirationLambdaEdgeProps extends cdk.StackProps {
bucketName: string
@@ -62,5 +63,6 @@ export class CheckExpirationLambdaEdge extends Construct {
})
this.lambdaEdge.addToRolePolicy(policyStatement)
+ addOutput(this, `${id}-CheckExpirationFunctionArn`, this.lambdaEdge.functionArn)
}
}
diff --git a/src/cdk/constructs/CloudFrontDistribution.ts b/src/cdk/constructs/CloudFrontDistribution.ts
index af46b40..bd8bc40 100644
--- a/src/cdk/constructs/CloudFrontDistribution.ts
+++ b/src/cdk/constructs/CloudFrontDistribution.ts
@@ -13,6 +13,8 @@ interface CloudFrontPropsDistribution {
requestEdgeFunction: cloudfront.experimental.EdgeFunction
responseEdgeFunction: cloudfront.experimental.EdgeFunction
cacheConfig: CacheConfig
+ customCloudFrontId?: string
+ customCloudFrontDomainName?: string
}
const OneMonthCache = Duration.days(30)
@@ -20,12 +22,19 @@ const NoCache = Duration.seconds(0)
const defaultNextQueries = ['_rsc']
const defaultNextHeaders = ['Cache-Control']
export class CloudFrontDistribution extends Construct {
- public readonly cf: cloudfront.Distribution
+ public readonly cf: cloudfront.IDistribution
constructor(scope: Construct, id: string, props: CloudFrontPropsDistribution) {
super(scope, id)
- const { staticBucket, requestEdgeFunction, responseEdgeFunction, cacheConfig } = props
+ const {
+ staticBucket,
+ requestEdgeFunction,
+ responseEdgeFunction,
+ cacheConfig,
+ customCloudFrontId,
+ customCloudFrontDomainName
+ } = props
const splitCachePolicy = new cloudfront.CachePolicy(this, 'SplitCachePolicy', {
cachePolicyName: `${id}-SplitCachePolicy`,
@@ -55,40 +64,50 @@ export class CloudFrontDistribution extends Construct {
const s3Origin = new origins.S3Origin(staticBucket)
- this.cf = new cloudfront.Distribution(this, id, {
- defaultBehavior: {
- origin: s3Origin,
- edgeLambdas: [
- {
- functionVersion: requestEdgeFunction.currentVersion,
- eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST
- },
- {
- functionVersion: responseEdgeFunction.currentVersion,
- eventType: cloudfront.LambdaEdgeEventType.ORIGIN_RESPONSE
- }
- ],
- cachePolicy: splitCachePolicy
- },
- defaultRootObject: '',
- additionalBehaviors: {
- ['/_next/data/*']: {
+ if (customCloudFrontId && customCloudFrontDomainName) {
+ this.cf = cloudfront.Distribution.fromDistributionAttributes(this, id, {
+ domainName: customCloudFrontId,
+ distributionId: customCloudFrontId
+ })
+ } else {
+ this.cf = new cloudfront.Distribution(this, id, {
+ defaultBehavior: {
origin: s3Origin,
edgeLambdas: [
{
functionVersion: requestEdgeFunction.currentVersion,
eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST
+ },
+ {
+ functionVersion: responseEdgeFunction.currentVersion,
+ eventType: cloudfront.LambdaEdgeEventType.ORIGIN_RESPONSE
}
],
cachePolicy: splitCachePolicy
},
- '/_next/*': {
- origin: s3Origin,
- cachePolicy: longCachePolicy
+ defaultRootObject: '',
+ additionalBehaviors: {
+ ['/_next/data/*']: {
+ origin: s3Origin,
+ edgeLambdas: [
+ {
+ functionVersion: requestEdgeFunction.currentVersion,
+ eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST
+ }
+ ],
+ cachePolicy: splitCachePolicy
+ },
+ '/_next/*': {
+ origin: s3Origin,
+ cachePolicy: longCachePolicy
+ }
}
- }
- })
+ })
+ }
addOutput(this, `${id}-CloudfrontDistributionId`, this.cf.distributionId)
+ addOutput(this, `${id}-SplitCachePolicyId`, splitCachePolicy.cachePolicyId)
+ addOutput(this, `${id}-LongCachePolicyId`, longCachePolicy.cachePolicyId)
+ addOutput(this, `${id}-StaticBucketRegionalDomainName`, staticBucket.bucketRegionalDomainName)
}
}
diff --git a/src/cdk/constructs/RoutingLambdaEdge.ts b/src/cdk/constructs/RoutingLambdaEdge.ts
index e3a0015..46d91fb 100644
--- a/src/cdk/constructs/RoutingLambdaEdge.ts
+++ b/src/cdk/constructs/RoutingLambdaEdge.ts
@@ -7,6 +7,7 @@ import * as iam from 'aws-cdk-lib/aws-iam'
import path from 'node:path'
import { buildLambda } from '../../build/edge'
import { CacheConfig } from '../../types'
+import { addOutput } from '../../common/cdk'
interface RoutingLambdaEdgeProps extends cdk.StackProps {
bucketName: string
@@ -62,5 +63,6 @@ export class RoutingLambdaEdge extends Construct {
})
this.lambdaEdge.addToRolePolicy(policyStatement)
+ addOutput(this, `${id}-RoutingFunctionArn`, this.lambdaEdge.functionArn)
}
}
diff --git a/src/cdk/stacks/NextCloudfrontStack.ts b/src/cdk/stacks/NextCloudfrontStack.ts
index 444173d..44546b9 100644
--- a/src/cdk/stacks/NextCloudfrontStack.ts
+++ b/src/cdk/stacks/NextCloudfrontStack.ts
@@ -1,6 +1,7 @@
import { Stack, type StackProps } from 'aws-cdk-lib'
import { Construct } from 'constructs'
import * as s3 from 'aws-cdk-lib/aws-s3'
+import * as cloudfront from '@aws-sdk/client-cloudfront'
import { RoutingLambdaEdge } from '../constructs/RoutingLambdaEdge'
import { CloudFrontDistribution } from '../constructs/CloudFrontDistribution'
import { CacheConfig } from '../../types'
@@ -13,6 +14,7 @@ export interface NextCloudfrontStackProps extends StackProps {
ebAppDomain: string
buildOutputPath: string
cacheConfig: CacheConfig
+ customCloudFrontDistribution?: cloudfront.Distribution
}
export class NextCloudfrontStack extends Stack {
@@ -22,7 +24,15 @@ export class NextCloudfrontStack extends Stack {
constructor(scope: Construct, id: string, props: NextCloudfrontStackProps) {
super(scope, id, props)
- const { nodejs, buildOutputPath, staticBucketName, ebAppDomain, region, cacheConfig } = props
+ const {
+ nodejs,
+ buildOutputPath,
+ staticBucketName,
+ ebAppDomain,
+ region,
+ cacheConfig,
+ customCloudFrontDistribution
+ } = props
this.routingLambdaEdge = new RoutingLambdaEdge(this, `${id}-RoutingLambdaEdge`, {
nodejs,
@@ -52,7 +62,9 @@ export class NextCloudfrontStack extends Stack {
ebAppDomain,
requestEdgeFunction: this.routingLambdaEdge.lambdaEdge,
responseEdgeFunction: this.checkExpLambdaEdge.lambdaEdge,
- cacheConfig
+ cacheConfig,
+ customCloudFrontId: customCloudFrontDistribution?.Id,
+ customCloudFrontDomainName: customCloudFrontDistribution?.DomainName
})
staticBucket.grantRead(this.routingLambdaEdge.lambdaEdge)
diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts
index 059dcde..908d129 100644
--- a/src/commands/deploy.ts
+++ b/src/commands/deploy.ts
@@ -1,13 +1,21 @@
import { ElasticBeanstalk } from '@aws-sdk/client-elastic-beanstalk'
import { S3 } from '@aws-sdk/client-s3'
-import { CloudFront } from '@aws-sdk/client-cloudfront'
+import { CloudFront, GetDistributionCommandOutput } from '@aws-sdk/client-cloudfront'
import fs from 'node:fs'
import childProcess from 'node:child_process'
import path from 'node:path'
import { buildApp, OUTPUT_FOLDER } from '../build/next'
import { NextRenderServerStack, type NextRenderServerStackProps } from '../cdk/stacks/NextRenderServerStack'
import { NextCloudfrontStack, type NextCloudfrontStackProps } from '../cdk/stacks/NextCloudfrontStack'
-import { getAWSCredentials, uploadFolderToS3, uploadFileToS3, AWS_EDGE_REGION, emptyBucket } from '../common/aws'
+import {
+ getAWSCredentials,
+ uploadFolderToS3,
+ uploadFileToS3,
+ AWS_EDGE_REGION,
+ emptyBucket,
+ updateDistribution,
+ getCloudFrontDistribution
+} from '../common/aws'
import { AppStack } from '../common/cdk'
import { getProjectSettings } from '../common/project'
import loadConfig from './helpers/loadConfig'
@@ -21,6 +29,8 @@ export interface DeployConfig {
region?: string
profile?: string
}
+ cloudFrontId?: string
+ skipDefaultBehavior?: boolean
}
export interface DeployStackProps {
@@ -53,9 +63,10 @@ const createOutputFolder = () => {
export const deploy = async (config: DeployConfig) => {
let cleanNextApp
try {
- const { siteName, stage = 'development', aws } = config
+ const { siteName, stage = 'development', aws, cloudFrontId, skipDefaultBehavior } = config
const credentials = await getAWSCredentials({ region: config.aws.region, profile: config.aws.profile })
const region = aws.region || process.env.REGION
+ let customCFDistribution: GetDistributionCommandOutput | undefined
if (!credentials.accessKeyId || !credentials.secretAccessKey) {
throw new Error('AWS Credentials are required.')
@@ -97,6 +108,10 @@ export const deploy = async (config: DeployConfig) => {
}
const siteNameLowerCased = siteName.toLowerCase()
+ if (cloudFrontId) {
+ customCFDistribution = await getCloudFrontDistribution(cloudfrontClient, cloudFrontId)
+ }
+
const nextRenderServerStack = new AppStack(
`${siteNameLowerCased}-server`,
NextRenderServerStack,
@@ -132,7 +147,8 @@ export const deploy = async (config: DeployConfig) => {
cacheConfig,
env: {
region: AWS_EDGE_REGION // required since Edge can be deployed only here.
- }
+ },
+ customCloudFrontDistribution: customCFDistribution?.Distribution
}
)
const nextCloudfrontStackOutput = await nextCloudfrontStack.deployStack()
@@ -189,6 +205,19 @@ export const deploy = async (config: DeployConfig) => {
VersionLabel: versionLabel
})
+ // if custom cf distribution, update it
+ if (customCFDistribution) {
+ await updateDistribution(cloudfrontClient, customCFDistribution, {
+ longCachePolicyId: nextCloudfrontStackOutput.LongCachePolicyId!,
+ splitCachePolicyId: nextCloudfrontStackOutput.SplitCachePolicyId!,
+ routingFunctionArn: nextCloudfrontStackOutput.RoutingFunctionArn!,
+ checkExpirationFunctionArn: nextCloudfrontStackOutput.CheckExpirationFunctionArn!,
+ staticBucketName: nextCloudfrontStackOutput.StaticBucketRegionalDomainName!,
+ addAdditionalBehaviour: true,
+ skipDefaultBehavior
+ })
+ }
+
await cloudfrontClient.createInvalidation({
DistributionId: nextCloudfrontStackOutput.CloudfrontDistributionId!,
InvalidationBatch: {
diff --git a/src/common/aws.ts b/src/common/aws.ts
index b69c7c6..cd05ee3 100644
--- a/src/common/aws.ts
+++ b/src/common/aws.ts
@@ -6,6 +6,16 @@ import * as AWS from 'aws-sdk'
import { partition } from '@aws-sdk/util-endpoints'
import fs from 'node:fs'
import path from 'node:path'
+import {
+ CloudFront,
+ UpdateDistributionCommand,
+ GetDistributionCommand,
+ CacheBehavior,
+ GetDistributionCommandOutput,
+ Distribution
+} from '@aws-sdk/client-cloudfront'
+import { LambdaEdgeEventType } from 'aws-cdk-lib/aws-cloudfront'
+import { UpdateCloudFrontDistribution } from '../types'
type GetAWSBasicProps =
| {
@@ -197,3 +207,225 @@ export const getCDKAssetsPublisher = (
) => {
return new AssetPublishing(AssetManifest.fromFile(manifestPath), { aws: new AWSClient(region, profile) })
}
+
+const behaviorMapper = (config: {
+ targetOriginId?: string
+ functionArn?: string
+ cachePolicyId: string
+ pathPattern?: string
+}): CacheBehavior => {
+ const { pathPattern, targetOriginId, functionArn, cachePolicyId } = config
+
+ return {
+ PathPattern: pathPattern,
+ TargetOriginId: targetOriginId,
+ ViewerProtocolPolicy: 'allow-all',
+ LambdaFunctionAssociations: functionArn
+ ? {
+ Quantity: 1,
+ Items: [
+ {
+ EventType: LambdaEdgeEventType.ORIGIN_REQUEST,
+ LambdaFunctionARN: functionArn
+ }
+ ]
+ }
+ : {
+ Quantity: 0,
+ Items: []
+ },
+ CachePolicyId: cachePolicyId,
+ SmoothStreaming: false,
+ Compress: true,
+ FieldLevelEncryptionId: '',
+ AllowedMethods: {
+ Quantity: 2,
+ Items: ['GET', 'HEAD'],
+ CachedMethods: {
+ Quantity: 2,
+ Items: ['GET', 'HEAD']
+ }
+ }
+ }
+}
+
+export const getCloudFrontDistribution = async (cfClient: CloudFront, distributionId: string) => {
+ const command = new GetDistributionCommand({ Id: distributionId })
+ const response = await cfClient.send(command)
+ return response
+}
+
+export const shouldUpdateDistro = (config: UpdateCloudFrontDistribution, distribution?: Distribution) => {
+ const {
+ staticBucketName,
+ routingFunctionArn,
+ splitCachePolicyId,
+ addAdditionalBehaviour,
+ longCachePolicyId,
+ checkExpirationFunctionArn
+ } = config
+
+ const targetOriginId = distribution?.DistributionConfig?.DefaultCacheBehavior?.TargetOriginId
+
+ if (staticBucketName && distribution?.DistributionConfig?.Origins && distribution.DistributionConfig.Origins.Items) {
+ const mainOrigin = distribution.DistributionConfig.Origins.Items?.find((origin) => origin.Id === targetOriginId)
+
+ if (mainOrigin && mainOrigin.DomainName !== staticBucketName) {
+ return true
+ }
+ }
+
+ if (addAdditionalBehaviour) {
+ const _nextDataBehaviour = (distribution?.DistributionConfig?.CacheBehaviors?.Items || []).find(
+ (b) => b.PathPattern === '/_next/data/*'
+ )
+
+ if (
+ !_nextDataBehaviour ||
+ (_nextDataBehaviour &&
+ (_nextDataBehaviour.CachePolicyId !== splitCachePolicyId ||
+ !_nextDataBehaviour.LambdaFunctionAssociations?.Items?.find(
+ (item) => item.LambdaFunctionARN === routingFunctionArn
+ )))
+ ) {
+ return true
+ }
+
+ const _nextBehaviour = (distribution?.DistributionConfig?.CacheBehaviors?.Items || []).find(
+ (b) => b.PathPattern === '/_next/*'
+ )
+
+ if (!_nextBehaviour || _nextBehaviour.CachePolicyId !== longCachePolicyId) {
+ return true
+ }
+ }
+
+ const defBehavior = distribution?.DistributionConfig?.DefaultCacheBehavior
+ const originReqLambdaFunc = defBehavior?.LambdaFunctionAssociations?.Items?.find(
+ (item) => item.LambdaFunctionARN === routingFunctionArn
+ )
+ const originResLambdaFunc = defBehavior?.LambdaFunctionAssociations?.Items?.find(
+ (item) => item.LambdaFunctionARN === checkExpirationFunctionArn
+ )
+
+ if (defBehavior?.CachePolicyId !== splitCachePolicyId || !originResLambdaFunc || !originReqLambdaFunc) {
+ return true
+ }
+
+ return false
+}
+
+export const updateDistribution = async (
+ cfClient: CloudFront,
+ distribution: GetDistributionCommandOutput,
+ config: UpdateCloudFrontDistribution
+) => {
+ const {
+ staticBucketName,
+ routingFunctionArn,
+ splitCachePolicyId,
+ addAdditionalBehaviour,
+ longCachePolicyId,
+ checkExpirationFunctionArn,
+ skipDefaultBehavior
+ } = config
+ const { Distribution, ETag } = distribution
+
+ //shouldn't update distribution if nothing changed
+ if (!shouldUpdateDistro(config, Distribution)) {
+ return
+ }
+
+ if (Distribution && Distribution.DistributionConfig) {
+ const targetOriginId = Distribution.DistributionConfig?.DefaultCacheBehavior?.TargetOriginId
+ if (staticBucketName && Distribution.DistributionConfig.Origins && Distribution.DistributionConfig.Origins.Items) {
+ const updatedOrigins = Distribution.DistributionConfig.Origins.Items?.map((origin) => {
+ if (origin.Id === targetOriginId) {
+ return {
+ ...origin,
+ DomainName: staticBucketName,
+ CustomOriginConfig: undefined // Remove any custom origin settings
+ }
+ }
+ return origin
+ })
+
+ // Update the Origins with the modified origin
+ Distribution.DistributionConfig.Origins.Items = updatedOrigins
+ }
+
+ if (addAdditionalBehaviour) {
+ const behaviours: CacheBehavior[] = [
+ behaviorMapper({
+ pathPattern: '/_next/data/*',
+ targetOriginId,
+ cachePolicyId: splitCachePolicyId!,
+ functionArn: routingFunctionArn
+ }),
+ behaviorMapper({
+ pathPattern: '/_next/*',
+ targetOriginId,
+ cachePolicyId: longCachePolicyId!
+ })
+ ]
+ const updatedBehaviors = behaviours.map((behaviour) => {
+ const oldBehavior = (Distribution.DistributionConfig?.CacheBehaviors?.Items || []).find(
+ (b) => b.PathPattern === behaviour.PathPattern
+ )
+ if (oldBehavior) {
+ return {
+ ...oldBehavior,
+ ...behaviour
+ }
+ }
+ return behaviour
+ })
+
+ const mergedBehaviours = (Distribution.DistributionConfig?.CacheBehaviors?.Items || [])
+ .filter((a) => !updatedBehaviors.find((b) => a.PathPattern === b.PathPattern))
+ .concat(updatedBehaviors)
+
+ Distribution.DistributionConfig.CacheBehaviors = {
+ Items: mergedBehaviours,
+ Quantity: mergedBehaviours.length
+ }
+ }
+
+ if (!skipDefaultBehavior) {
+ const defBeh = Distribution.DistributionConfig.DefaultCacheBehavior
+
+ Distribution.DistributionConfig.DefaultCacheBehavior = {
+ ...defBeh,
+ LambdaFunctionAssociations: {
+ Quantity: 2,
+ Items: [
+ {
+ EventType: LambdaEdgeEventType.ORIGIN_REQUEST,
+ LambdaFunctionARN: routingFunctionArn
+ },
+ {
+ EventType: LambdaEdgeEventType.ORIGIN_RESPONSE,
+ LambdaFunctionARN: checkExpirationFunctionArn
+ }
+ ]
+ },
+ TargetOriginId: targetOriginId,
+ ViewerProtocolPolicy: 'allow-all',
+ SmoothStreaming: false,
+ Compress: true,
+ CachePolicyId: splitCachePolicyId
+ }
+ }
+ }
+
+ // Update the distribution with the modified config
+ const updateParams = {
+ Id: Distribution?.Id,
+ IfMatch: ETag, // Required for updating the distribution
+ DistributionConfig: Distribution?.DistributionConfig
+ }
+ const command = new UpdateDistributionCommand(updateParams)
+ const updateResponse = await cfClient.send(command)
+
+ return updateResponse
+}
diff --git a/src/index.ts b/src/index.ts
index 8e69a4f..07443aa 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -11,6 +11,8 @@ interface CLIOptions {
profile?: string
nodejs?: string
production?: boolean
+ cloudFrontId?: string
+ skipDefaultBehavior?: boolean
}
const cli = yargs(hideBin(process.argv))
@@ -40,6 +42,15 @@ const cli = yargs(hideBin(process.argv))
description: 'Creates production stack.',
default: false
})
+ .option('cloudFrontId', {
+ type: 'string',
+ describe: 'Existing cloudfront Id.'
+ })
+ .option('skipDefaultBehavior', {
+ type: 'boolean',
+ description: 'Skip updating default cloudfront default behavior.',
+ default: false
+ })
cli.command(
'bootstrap',
@@ -56,13 +67,15 @@ cli.command(
'app deployment',
() => {},
async (argv) => {
- const { siteName, stage, region, profile, nodejs, production } = argv
+ const { siteName, stage, region, profile, nodejs, production, cloudFrontId, skipDefaultBehavior } = argv
await deploy({
siteName,
stage,
nodejs,
isProduction: production,
+ cloudFrontId,
+ skipDefaultBehavior,
aws: {
region,
profile
diff --git a/src/types/index.ts b/src/types/index.ts
index 921261f..cd94ba9 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -4,3 +4,13 @@ export interface CacheConfig {
cacheQueries?: string[]
enableDeviceSplit?: boolean
}
+
+export interface UpdateCloudFrontDistribution {
+ staticBucketName?: string
+ longCachePolicyId?: string
+ splitCachePolicyId?: string
+ routingFunctionArn?: string
+ checkExpirationFunctionArn?: string
+ addAdditionalBehaviour?: boolean
+ skipDefaultBehavior?: boolean
+}