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 +}