import fs from 'node:fs'; import path from 'node:path'; import { CfnOutput, Duration, RemovalPolicy, Size, Stack } from 'aws-cdk-lib'; import * as acm from 'aws-cdk-lib/aws-certificatemanager'; import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; import { experimental as cloudfrontExperimental } from 'aws-cdk-lib/aws-cloudfront'; import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as route53 from 'aws-cdk-lib/aws-route53'; import * as route53Targets from 'aws-cdk-lib/aws-route53-targets'; import * as s3 from 'aws-cdk-lib/aws-s3'; import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import * as sqs from 'aws-cdk-lib/aws-sqs'; import * as logs from 'aws-cdk-lib/aws-logs'; import { Construct } from 'constructs'; import { FunctionOriginResource, ImageOptimizationResources, OpenNextFunctionOrigin, OpenNextOutput, OpenNextS3Origin, PortfolioStackProps, } from './types'; import { buildEdgeRuntimeHeaders, buildEdgeSecretHeaders, patchEdgeServerBundle, EDGE_RUNTIME_HEADER_NAME, EDGE_ENV_SECRET_HEADER_NAME, EDGE_REPO_SECRET_HEADER_NAME, EDGE_SECRETS_REGION_HEADER_NAME, EDGE_SECRETS_FALLBACK_REGION_HEADER_NAME, } from './config/edge-runtime'; import { buildEnvironmentFromRules, resolveEdgeRuntimeEnvRules } from './config/env-rules'; import { BlogInfra } from './constructs/blog-infra'; import { ChatInfra } from './constructs/chat-infra'; import { CacheInfra } from './constructs/cache-infra';
export class PortfolioStack extends Stack { private readonly appDirectoryPath: string; private readonly openNextDir: string; private readonly openNextOutput: OpenNextOutput; private readonly assetsBucket: s3.Bucket; private readonly revalidationTable: dynamodb.Table; private readonly revalidationQueue: sqs.Queue; private readonly runtimeEnvironment: Record<string, string>; private readonly envSecret?: secretsmanager.ISecret; private readonly repoSecret?: secretsmanager.ISecret; private readonly edgeRuntimeEntriesPerHeader = 8; private edgeRuntimeHeaderValues?: Record<string, string>; private readonly protectedFunctionUrls: { url: lambda.IFunctionUrl; fn: lambda.Function }[] = []; private readonly postsTable: dynamodb.Table; private readonly adminDataTable: dynamodb.Table; private readonly blogContentBucket: s3.Bucket; private readonly blogMediaBucket: s3.Bucket; private readonly chatExportBucket: s3.Bucket; private readonly chatCostTable: dynamodb.Table; private readonly alternateDomains: string[] = []; private readonly primaryDomainName?: string; private readonly validationMode: boolean;
constructor(scope: Construct, id: string, props: PortfolioStackProps = {}) { super(scope, id, props);
const {
domainName,
hostedZoneDomain,
certificateArn,
alternateDomainNames = [],
environment = {},
appDirectory = path.resolve(process.cwd(), '..', '..'),
openNextPath,
validationMode = false,
} = props;
this.validationMode = validationMode;
this.primaryDomainName = domainName;
this.alternateDomains = alternateDomainNames;
this.appDirectoryPath = appDirectory;
const hostedZone =
domainName && hostedZoneDomain
? route53.HostedZone.fromLookup(this, 'PortfolioHostedZone', { domainName: hostedZoneDomain })
: undefined;
const certificate =
domainName && certificateArn
? acm.Certificate.fromCertificateArn(this, 'PortfolioCertificate', certificateArn)
: domainName && hostedZone
? new acm.Certificate(this, 'PortfolioCertificate', {
domainName,
validation: acm.CertificateValidation.fromDns(hostedZone),
subjectAlternativeNames: alternateDomainNames,
})
: undefined;
this.runtimeEnvironment = this.enrichRuntimeEnvironment(environment);
this.openNextDir = this.resolveOpenNextDirectory(openNextPath, appDirectory);
this.openNextOutput = this.readOpenNextOutput();
this.envSecret = this.resolveSecretReference(
'EnvSecretRef',
this.runtimeEnvironment['SECRETS_MANAGER_ENV_SECRET_ID']
);
this.repoSecret = this.resolveSecretReference(
'RepoSecretRef',
this.runtimeEnvironment['SECRETS_MANAGER_REPO_SECRET_ID']
);
this.assetsBucket = this.createAssetsBucket();
const blogInfra = new BlogInfra(this, 'BlogInfra', {
runtimeEnvironment: this.runtimeEnvironment,
primaryDomainName: this.primaryDomainName,
alternateDomainNames,
});
this.postsTable = blogInfra.postsTable;
this.adminDataTable = blogInfra.adminDataTable;
this.blogContentBucket = blogInfra.contentBucket;
this.blogMediaBucket = blogInfra.mediaBucket;
const chatInfra = new ChatInfra(this, 'ChatInfra', {
runtimeEnvironment: this.runtimeEnvironment,
});
this.chatExportBucket = chatInfra.chatExportBucket;
this.chatCostTable = chatInfra.chatCostTable;
const cacheInfra = new CacheInfra(this, 'CacheInfra', {
openNextOutput: this.openNextOutput,
resolveBundlePath: this.resolveBundlePath.bind(this),
grantSecretAccess: this.grantSecretAccess.bind(this),
});
this.revalidationTable = cacheInfra.revalidationTable;
this.revalidationQueue = cacheInfra.revalidationQueue;
const baseEnv = this.buildBaseEnvironment();
cacheInfra.addInitializer(baseEnv, (keys) => this.buildLambdaRuntimeEnv(baseEnv, keys));
const revalidationWorker = cacheInfra.addRevalidationConsumer(baseEnv, (keys) =>
this.buildLambdaRuntimeEnv(baseEnv, keys)
);
if (revalidationWorker) {
this.grantRuntimeAccess(revalidationWorker, { allowQueueSend: false });
this.revalidationQueue.grantConsumeMessages(revalidationWorker);
this.grantSecretAccess(revalidationWorker);
}
const serverEdgeFunction = this.createServerEdgeFunction(baseEnv);
const serverEdgeFunctionResource = serverEdgeFunction.node.defaultChild as lambda.CfnFunction | undefined;
if (serverEdgeFunctionResource) {
serverEdgeFunctionResource.applyRemovalPolicy(RemovalPolicy.RETAIN);
}
const serverEdgeFunctionVersion = serverEdgeFunction.currentVersion.node.defaultChild as
| lambda.CfnVersion
| undefined;
if (serverEdgeFunctionVersion) {
serverEdgeFunctionVersion.applyRemovalPolicy(RemovalPolicy.RETAIN);
}
this.grantRuntimeAccess(serverEdgeFunction);
this.grantBlogDataAccess(serverEdgeFunction);
this.attachSesPermissions(serverEdgeFunction);
this.grantSecretAccess(serverEdgeFunction, serverEdgeFunction);
this.attachCostMetricPermissions(serverEdgeFunction);
const imageResources = this.createImageOptimizationResources(baseEnv);
if (imageResources) {
this.grantRuntimeAccess(imageResources.function);
this.grantSecretAccess(imageResources.function);
}
const additionalOrigins = this.createAdditionalOrigins(baseEnv);
for (const resource of Object.values(additionalOrigins)) {
if (resource.function) {
this.grantRuntimeAccess(resource.function);
this.grantSecretAccess(resource.function);
this.attachSesPermissions(resource.function);
}
}
const serverCachePolicy = this.createServerCachePolicy();
const imageCachePolicy = this.createImageCachePolicy();
const staticCachePolicy = cloudfront.CachePolicy.CACHING_OPTIMIZED;
const responseHeadersPolicy = cloudfront.ResponseHeadersPolicy.SECURITY_HEADERS;
const originCustomHeaders = this.buildOriginCustomHeaders();
const s3OriginProps: origins.S3BucketOriginWithOACProps = {
originAccessLevels: [cloudfront.AccessLevel.READ],
originPath: this.openNextOutput.origins.s3.originPath,
...(Object.keys(originCustomHeaders).length ? { customHeaders: originCustomHeaders } : {}),
};
const staticOrigin = origins.S3BucketOrigin.withOriginAccessControl(this.assetsBucket, s3OriginProps);
const originMap: Record<string, cloudfront.IOrigin> = {
s3: staticOrigin,
default: staticOrigin,
};
if (imageResources) {
originMap.imageOptimizer = imageResources.origin;
}
for (const [key, resource] of Object.entries(additionalOrigins)) {
originMap[key] = resource.origin;
}
const domainConfig = this.resolveDistributionDomainConfig(domainName, alternateDomainNames, certificate);
const distribution = new cloudfront.Distribution(this, 'PortfolioDistribution', {
defaultBehavior: {
origin: staticOrigin,
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD_OPTIONS,
cachePolicy: serverCachePolicy,
originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
responseHeadersPolicy,
edgeLambdas: [
{
eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST,
functionVersion: serverEdgeFunction.currentVersion,
includeBody: true,
},
],
},
additionalBehaviors: this.buildAdditionalBehaviors({
serverCachePolicy,
imageCachePolicy,
staticCachePolicy,
serverEdgeFunction,
originMap,
responseHeadersPolicy,
}),
httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
priceClass: cloudfront.PriceClass.PRICE_CLASS_200,
...(domainConfig?.domainNames ? { domainNames: domainConfig.domainNames } : {}),
...(domainConfig?.certificate ? { certificate: domainConfig.certificate } : {}),
});
const edgeFunction = serverEdgeFunction.lambda as lambda.Function;
edgeFunction.addEnvironment('CLOUDFRONT_DISTRIBUTION_ID', distribution.distributionId, {
removeInEdge: true,
});
if (!this.validationMode) {
this.attachCloudFrontInvalidationPermission(serverEdgeFunction);
}
this.deployStaticAssets(this.openNextOutput.origins.s3, distribution);
this.restrictFunctionUrlAccess(distribution);
if (hostedZone && domainConfig?.domainNames?.length) {
this.createAliasRecords(hostedZone, distribution, domainConfig.domainNames);
}
new CfnOutput(this, 'DistributionDomainName', {
value: distribution.distributionDomainName,
});
new CfnOutput(this, 'DistributionId', {
value: distribution.distributionId,
});
new CfnOutput(this, 'AssetBucketName', {
value: this.assetsBucket.bucketName,
});
new CfnOutput(this, 'BlogPostsTableName', {
value: this.postsTable.tableName,
});
new CfnOutput(this, 'BlogContentBucketName', {
value: this.blogContentBucket.bucketName,
});
new CfnOutput(this, 'BlogMediaBucketName', {
value: this.blogMediaBucket.bucketName,
});
new CfnOutput(this, 'ChatExportBucketName', {
value: this.chatExportBucket.bucketName,
});
new CfnOutput(this, 'AdminDataTableName', {
value: this.adminDataTable.tableName,
});
new CfnOutput(this, 'ChatRuntimeCostTableName', {
value: this.chatCostTable.tableName,
});
}
private enrichRuntimeEnvironment(environment: Record<string, string>): Record<string, string> { const env: Record<string, string> = { NODE_ENV: 'production', ...environment, }; const allowProdFixtures = env['ALLOW_TEST_FIXTURES_IN_PROD'] === 'true'; if (!allowProdFixtures) { delete env['BLOG_TEST_FIXTURES']; delete env['PORTFOLIO_TEST_FIXTURES']; }
if (!env['AWS_SECRETS_MANAGER_PRIMARY_REGION'] && env['AWS_REGION']) {
env['AWS_SECRETS_MANAGER_PRIMARY_REGION'] = env['AWS_REGION'];
}
return env;
}
private resolveOpenNextDirectory(explicitPath: string | undefined, appDirectory: string): string { const candidate = explicitPath ? path.resolve(explicitPath) : path.resolve(appDirectory, '.open-next');
if (!fs.existsSync(candidate)) {
throw new Error(
`OpenNext build output not found at ${candidate}. Run 'pnpm run build:web' before synthesizing the CDK app.`
);
}
const outputFile = path.join(candidate, 'open-next.output.json');
if (!fs.existsSync(outputFile)) {
throw new Error(
`Missing open-next.output.json at ${outputFile}. Ensure '@opennextjs/aws build' has completed successfully.`
);
}
return candidate;
}
private readOpenNextOutput(): OpenNextOutput { const outputPath = path.join(this.openNextDir, 'open-next.output.json'); const raw = fs.readFileSync(outputPath, 'utf-8'); return JSON.parse(raw) as OpenNextOutput; }
private resolveSecretReference(id: string, secretId?: string): secretsmanager.ISecret | undefined { if (!secretId) { return undefined; }
return secretId.startsWith('arn:')
? secretsmanager.Secret.fromSecretCompleteArn(this, id, secretId)
: secretsmanager.Secret.fromSecretNameV2(this, id, secretId);
}
private createAssetsBucket(): s3.Bucket { return new s3.Bucket(this, 'AssetsBucket', { blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, enforceSSL: true, encryption: s3.BucketEncryption.S3_MANAGED, versioned: true, removalPolicy: RemovalPolicy.RETAIN, autoDeleteObjects: false, }); }
private deployStaticAssets(config: OpenNextS3Origin, distribution: cloudfront.IDistribution) {
config.copy.forEach((copy, index) => {
const sourcePath = this.resolveBundlePath(copy.from);
if (!fs.existsSync(sourcePath)) {
throw new Error(Static asset source not found: ${sourcePath});
}
new s3deploy.BucketDeployment(this, `AssetsDeployment${index}`, {
sources: [
s3deploy.Source.asset(sourcePath, {
exclude: ['**/*.map', '**/.DS_Store'],
}),
],
destinationBucket: this.assetsBucket,
destinationKeyPrefix: copy.to.replace(/^\//, ''),
prune: false,
distribution,
distributionPaths: ['/*'],
memoryLimit: 2048,
ephemeralStorageSize: Size.gibibytes(4),
cacheControl: copy.cached
? [
s3deploy.CacheControl.setPublic(),
s3deploy.CacheControl.immutable(),
s3deploy.CacheControl.maxAge(Duration.days(365)),
]
: [s3deploy.CacheControl.setPublic(), s3deploy.CacheControl.noCache()],
});
});
}
private buildBaseEnvironment(): Record<string, string> { const env: Record<string, string> = { ...this.runtimeEnvironment }; const region = Stack.of(this).region;
if (!env['AWS_REGION']) {
env['AWS_REGION'] = region;
}
env['CACHE_BUCKET_NAME'] = this.assetsBucket.bucketName;
env['CACHE_BUCKET_KEY_PREFIX'] = env['CACHE_BUCKET_KEY_PREFIX'] ?? '_cache';
env['CACHE_BUCKET_REGION'] = region;
env['CACHE_DYNAMO_TABLE'] = this.revalidationTable.tableName;
env['COST_TABLE_NAME'] = this.chatCostTable.tableName;
env['REVALIDATION_QUEUE_URL'] = this.revalidationQueue.queueUrl;
env['REVALIDATION_QUEUE_REGION'] = region;
env['BUCKET_NAME'] = env['BUCKET_NAME'] ?? this.assetsBucket.bucketName;
env['POSTS_TABLE'] = this.postsTable.tableName;
env['ADMIN_TABLE_NAME'] = this.adminDataTable.tableName;
env['POSTS_STATUS_INDEX'] = 'byStatusPublishedAt';
env['CONTENT_BUCKET'] = this.blogContentBucket.bucketName;
env['MEDIA_BUCKET'] = this.blogMediaBucket.bucketName;
env['CHAT_EXPORT_BUCKET'] = this.chatExportBucket.bucketName;
const s3OriginPath = this.openNextOutput.origins.s3.originPath ?? '/';
env['BUCKET_KEY_PREFIX'] = s3OriginPath.replace(/^\//, ''); // '' if originPath is '/'
if (!env['AWS_SECRETS_MANAGER_PRIMARY_REGION']) {
env['AWS_SECRETS_MANAGER_PRIMARY_REGION'] = region;
}
if (!env['NEXTAUTH_URL']) {
const siteUrl =
env['NEXT_PUBLIC_SITE_URL'] ?? (this.primaryDomainName ? `https://${this.primaryDomainName}` : undefined);
if (siteUrl) {
env['NEXTAUTH_URL'] = siteUrl.replace(/\/$/, '');
}
}
return env;
}
private createServerEdgeFunction(baseEnv: Record<string, string>): cloudfrontExperimental.EdgeFunction { const serverOrigin = this.openNextOutput.origins.default; if (serverOrigin.type !== 'function') { throw new Error('OpenNext default origin must be of type "function".'); }
const bundlePath = this.resolveBundlePath(serverOrigin.bundle);
if (!fs.existsSync(bundlePath)) {
throw new Error(`Server bundle not found: ${bundlePath}`);
}
const edgeEnv = this.buildEdgeEnvironment(baseEnv);
this.assertEdgeEnvironment(edgeEnv);
this.edgeRuntimeHeaderValues = buildEdgeRuntimeHeaders(edgeEnv, {
runtimeHeaderName: EDGE_RUNTIME_HEADER_NAME,
entriesPerHeader: this.edgeRuntimeEntriesPerHeader,
});
if (!this.edgeRuntimeHeaderValues || Object.keys(this.edgeRuntimeHeaderValues).length === 0) {
throw new Error('Edge runtime configuration is empty; ensure required environment values are provided.');
}
patchEdgeServerBundle({
bundlePath,
runtimeHeaders: this.edgeRuntimeHeaderValues,
runtimeHeaderName: EDGE_RUNTIME_HEADER_NAME,
envSecretHeaderName: EDGE_ENV_SECRET_HEADER_NAME,
repoSecretHeaderName: EDGE_REPO_SECRET_HEADER_NAME,
secretsRegionHeaderName: EDGE_SECRETS_REGION_HEADER_NAME,
secretsFallbackRegionHeaderName: EDGE_SECRETS_FALLBACK_REGION_HEADER_NAME,
});
return new cloudfrontExperimental.EdgeFunction(this, 'ServerEdgeFunction', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: serverOrigin.handler,
code: lambda.Code.fromAsset(bundlePath),
architecture: lambda.Architecture.X86_64,
memorySize: 1536,
timeout: Duration.seconds(30),
description: 'Next.js server Lambda@Edge',
});
}
private createImageOptimizationResources(baseEnv: Record<string, string>): ImageOptimizationResources | undefined { const imageOrigin = this.openNextOutput.origins.imageOptimizer; if (!imageOrigin?.bundle) { return undefined; }
const bundlePath = this.resolveBundlePath(imageOrigin.bundle);
if (!fs.existsSync(bundlePath)) {
return undefined;
}
const imageLogGroup = new logs.LogGroup(this, 'ImageOptimizationFunctionLogs', {
retention: logs.RetentionDays.TWO_WEEKS,
removalPolicy: RemovalPolicy.DESTROY,
});
const fn = new lambda.Function(this, 'ImageOptimizationFunction', {
runtime: lambda.Runtime.NODEJS_20_X,
architecture: lambda.Architecture.ARM_64,
handler: imageOrigin.handler,
code: lambda.Code.fromAsset(bundlePath),
timeout: Duration.seconds(30),
memorySize: 1024,
environment: this.buildLambdaRuntimeEnv(baseEnv, [
'BUCKET_NAME',
'BUCKET_KEY_PREFIX',
'CACHE_BUCKET_NAME',
'CACHE_BUCKET_KEY_PREFIX',
'CACHE_BUCKET_REGION',
'CACHE_DYNAMO_TABLE',
'AWS_REGION',
'AWS_SECRETS_MANAGER_PRIMARY_REGION',
'SECRETS_MANAGER_ENV_SECRET_ID',
'SECRETS_MANAGER_REPO_SECRET_ID',
]),
logGroup: imageLogGroup,
});
const functionUrl = fn.addFunctionUrl({
authType: lambda.FunctionUrlAuthType.AWS_IAM,
invokeMode: imageOrigin.streaming ? lambda.InvokeMode.RESPONSE_STREAM : lambda.InvokeMode.BUFFERED,
});
this.protectedFunctionUrls.push({ url: functionUrl, fn });
const origin = origins.FunctionUrlOrigin.withOriginAccessControl(functionUrl, {
originAccessControl: new cloudfront.FunctionUrlOriginAccessControl(this, 'ImageOptimizerOAC'),
});
return { function: fn, functionUrl, origin };
}
private createAdditionalOrigins(baseEnv: Record<string, string>): Record<string, FunctionOriginResource> { const result: Record<string, FunctionOriginResource> = {}; for (const [key, originDef] of Object.entries(this.openNextOutput.origins)) { if (['s3', 'default', 'imageOptimizer'].includes(key)) { continue; }
if ((originDef as OpenNextFunctionOrigin).type === 'function') {
const originConfig = originDef as OpenNextFunctionOrigin;
const bundlePath = this.resolveBundlePath(originConfig.bundle);
if (!fs.existsSync(bundlePath)) {
continue;
}
const functionId = this.toPascalCase(key);
const fnLogGroup = new logs.LogGroup(this, `${functionId}FunctionLogs`, {
retention: logs.RetentionDays.TWO_WEEKS,
removalPolicy: RemovalPolicy.DESTROY,
});
const fn = new lambda.Function(this, `${functionId}Function`, {
runtime: lambda.Runtime.NODEJS_20_X,
architecture: lambda.Architecture.ARM_64,
handler: originConfig.handler,
code: lambda.Code.fromAsset(bundlePath),
timeout: Duration.seconds(30),
memorySize: 1024,
environment: this.buildLambdaRuntimeEnv(baseEnv),
logGroup: fnLogGroup,
});
// The chat endpoint must accept unsigned requests from CloudFront viewers,
// so disable IAM auth just for that origin to avoid SigV4 errors.
const functionAuthType =
key === 'chat' ? lambda.FunctionUrlAuthType.NONE : lambda.FunctionUrlAuthType.AWS_IAM;
const oacSigning =
functionAuthType === lambda.FunctionUrlAuthType.NONE ? cloudfront.Signing.NEVER : undefined;
const fnUrl = fn.addFunctionUrl({
authType: functionAuthType,
invokeMode: originConfig.streaming ? lambda.InvokeMode.RESPONSE_STREAM : lambda.InvokeMode.BUFFERED,
});
this.protectedFunctionUrls.push({ url: fnUrl, fn });
const customHeaders = this.buildOriginCustomHeaders();
const originResource = origins.FunctionUrlOrigin.withOriginAccessControl(fnUrl, {
originAccessControl: new cloudfront.FunctionUrlOriginAccessControl(this, `${functionId}FunctionOAC`, {
signing: oacSigning,
}),
customHeaders: Object.keys(customHeaders).length ? customHeaders : undefined,
});
result[key] = {
function: fn,
functionUrl: fnUrl,
origin: originResource,
};
}
}
return result;
}
private createServerCachePolicy(): cloudfront.CachePolicy {
return new cloudfront.CachePolicy(this, 'ServerCachePolicy', {
cachePolicyName: ${Stack.of(this).stackName}-Server,
comment: 'Cache policy for Next.js SSR and API routes',
defaultTtl: Duration.seconds(0),
minTtl: Duration.seconds(0),
maxTtl: Duration.days(365),
queryStringBehavior: cloudfront.CacheQueryStringBehavior.all(),
headerBehavior: cloudfront.CacheHeaderBehavior.allowList(
'accept',
'rsc',
'next-router-prefetch',
'next-router-state-tree',
'next-url',
'x-prerender-revalidate',
'x-revalidate-secret',
'x-chat-origin-secret',
'x-portfolio-test-mode'
),
cookieBehavior: cloudfront.CacheCookieBehavior.none(),
enableAcceptEncodingGzip: true,
enableAcceptEncodingBrotli: true,
});
}
private createImageCachePolicy(): cloudfront.CachePolicy {
return new cloudfront.CachePolicy(this, 'ImageCachePolicy', {
cachePolicyName: ${Stack.of(this).stackName}-Image,
comment: 'Cache policy for Next.js image optimization (/_next/image)',
defaultTtl: Duration.days(30),
maxTtl: Duration.days(365),
minTtl: Duration.seconds(0),
queryStringBehavior: cloudfront.CacheQueryStringBehavior.all(), // url, w, q
headerBehavior: cloudfront.CacheHeaderBehavior.allowList('accept'), // webp/avif variants
cookieBehavior: cloudfront.CacheCookieBehavior.none(),
enableAcceptEncodingBrotli: true,
enableAcceptEncodingGzip: true,
});
}
private buildAdditionalBehaviors(options: { serverCachePolicy: cloudfront.ICachePolicy; imageCachePolicy: cloudfront.ICachePolicy; staticCachePolicy: cloudfront.ICachePolicy; serverEdgeFunction: cloudfrontExperimental.EdgeFunction; originMap: Record<string, cloudfront.IOrigin>; responseHeadersPolicy: cloudfront.IResponseHeadersPolicy; }): Record<string, cloudfront.BehaviorOptions> { const { serverCachePolicy, imageCachePolicy, staticCachePolicy, serverEdgeFunction, originMap, responseHeadersPolicy, } = options;
const behaviors: Record<string, cloudfront.BehaviorOptions> = {};
for (const behavior of this.openNextOutput.behaviors ?? []) {
if (behavior.pattern === '*') {
continue;
}
const originKey = behavior.origin ?? 'default';
const origin = originMap[originKey];
if (!origin) {
throw new Error(`No origin registered for key '${originKey}' in OpenNext behaviors.`);
}
const isStatic = originKey === 's3';
const isImageOptimizer = originKey === 'imageOptimizer';
const isDefaultOrigin = originKey === 'default';
const cachePolicy = isImageOptimizer ? imageCachePolicy : isStatic ? staticCachePolicy : serverCachePolicy;
const allowedMethods =
isImageOptimizer || isStatic
? cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS
: cloudfront.AllowedMethods.ALLOW_ALL;
const originRequestPolicy = isImageOptimizer
? cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER
: !isStatic
? cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER
: undefined;
// Only attach edge function to the default origin. Custom function origins
// (like 'chat') handle requests themselves and don't need the edge function.
const edgeLambdas = isDefaultOrigin
? [
{
eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST,
functionVersion: serverEdgeFunction.currentVersion,
includeBody: true,
},
]
: undefined;
const behaviorOptions: cloudfront.BehaviorOptions = {
origin,
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowedMethods,
cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD_OPTIONS,
cachePolicy,
responseHeadersPolicy,
...(originRequestPolicy ? { originRequestPolicy } : {}),
...(edgeLambdas ? { edgeLambdas } : {}),
};
behaviors[behavior.pattern] = behaviorOptions;
}
return behaviors;
}
private resolveDistributionDomainConfig( domainName: string | undefined, alternateDomainNames: string[], certificate?: acm.ICertificate ): | { domainNames: string[]; certificate: acm.ICertificate; } | undefined { if (!domainName) { return undefined; }
if (!certificate) {
throw new Error(
'A CloudFront custom domain requires an ACM certificate in us-east-1. Provide certificateArn or configure a hosted zone to issue one.'
);
}
const names = [domainName, ...alternateDomainNames.filter(Boolean)];
return {
domainNames: names,
certificate,
};
}
private createAliasRecords( hostedZone: route53.IHostedZone, distribution: cloudfront.Distribution, domainNames: string[] ) { const target = route53.RecordTarget.fromAlias(new route53Targets.CloudFrontTarget(distribution));
domainNames.forEach((name, index) => {
new route53.ARecord(this, `AliasRecord${index}`, {
zone: hostedZone,
recordName: name,
target,
});
new route53.AaaaRecord(this, `AliasRecordAAAA${index}`, {
zone: hostedZone,
recordName: name,
target,
});
});
}
private resolveBundlePath(bundle: string): string { if (path.isAbsolute(bundle)) { return bundle; }
const trimmed = bundle.startsWith('./') ? bundle.slice(2) : bundle;
const normalized = trimmed.replace(/\\/g, '/');
if (normalized === '.open-next' || normalized.startsWith('.open-next/')) {
return path.resolve(this.appDirectoryPath, ...normalized.split('/'));
}
return path.join(this.openNextDir, ...normalized.split('/'));
}
private pickRuntimeEnv(source: Record<string, string>, keys: string[]): Record<string, string> { const subset: Record<string, string> = {}; for (const key of keys) { const value = source[key]; if (value !== undefined) { subset[key] = value; } } return this.filterReservedLambdaEnv(subset); }
private filterReservedLambdaEnv(env: Record<string, string>): Record<string, string> { const reserved = new Set([ 'AWS_REGION', 'AWS_DEFAULT_REGION', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', ]); const isValidLambdaEnvKey = (key: string): boolean => /^[A-Za-z][A-Za-z0-9_]*$/.test(key); const sanitized: Record<string, string> = {}; for (const [key, value] of Object.entries(env)) { if (reserved.has(key)) { continue; } // CloudFormation/Lambda require env var names to start with a letter and contain only letters, numbers, and underscores // Common CI shells inject '_' which is invalid and must be filtered out if (!isValidLambdaEnvKey(key)) { continue; } sanitized[key] = value; } return sanitized; }
private grantRuntimeAccess( grantable: iam.IGrantable, options: { allowCacheWrite?: boolean; allowQueueSend?: boolean } = {} ) { if (options.allowCacheWrite === false) { this.assetsBucket.grantRead(grantable); } else { this.assetsBucket.grantReadWrite(grantable); }
this.revalidationTable.grantReadWriteData(grantable);
this.chatCostTable.grantReadWriteData(grantable);
this.adminDataTable.grantReadWriteData(grantable);
if (options.allowQueueSend !== false) {
this.revalidationQueue.grantSendMessages(grantable);
}
}
private grantBlogDataAccess(grantable: iam.IGrantable) { this.postsTable.grantReadWriteData(grantable); this.blogContentBucket.grantReadWrite(grantable); this.blogMediaBucket.grantReadWrite(grantable); this.chatExportBucket.grantReadWrite(grantable); }
private attachCloudFrontInvalidationPermission(fn: cloudfrontExperimental.EdgeFunction) { const stack = Stack.of(this); const distributionArn = stack.formatArn({ service: 'cloudfront', region: '', account: stack.account, resource: 'distribution', resourceName: '*', });
fn.addToRolePolicy(
new iam.PolicyStatement({
actions: ['cloudfront:CreateInvalidation'],
resources: [distributionArn],
conditions: {
StringEquals: {
'aws:ResourceTag/aws:cloudformation:stack-id': Stack.of(this).stackId,
'aws:ResourceTag/aws:cloudformation:stack-name': Stack.of(this).stackName,
},
},
})
);
}
private attachSesPermissions(grantable: iam.IGrantable) { grantable.grantPrincipal.addToPrincipalPolicy( new iam.PolicyStatement({ actions: ['ses:SendEmail', 'ses:SendRawEmail'], resources: ['*'], }) ); }
private attachCostMetricPermissions(fn: cloudfrontExperimental.EdgeFunction) { fn.addToRolePolicy( new iam.PolicyStatement({ actions: ['cloudwatch:PutMetricData'], resources: ['*'], conditions: { StringEquals: { 'cloudwatch:namespace': this.runtimeEnvironment['OPENAI_COST_METRIC_NAMESPACE'] ?? 'PortfolioChat/OpenAI', }, }, }) ); }
private grantSecretAccess(grantable: iam.IGrantable, edgeFunction?: cloudfrontExperimental.EdgeFunction) { const configuredSecretIds = [ this.runtimeEnvironment['SECRETS_MANAGER_ENV_SECRET_ID'], this.runtimeEnvironment['SECRETS_MANAGER_REPO_SECRET_ID'], ].filter((value): value is string => typeof value === 'string' && value.length > 0);
if (this.envSecret) {
this.envSecret.grantRead(grantable);
}
if (this.repoSecret) {
this.repoSecret.grantRead(grantable);
}
const secretResourceArns = Array.from(
new Set(configuredSecretIds.flatMap((secretId) => this.buildSecretResourceArns(secretId)))
);
if (secretResourceArns.length > 0) {
grantable.grantPrincipal.addToPrincipalPolicy(
new iam.PolicyStatement({
actions: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'],
resources: secretResourceArns,
})
);
}
// For Lambda@Edge functions, explicitly add policy to handle imported secrets and
// ensure the replicated execution role receives the statement.
if (edgeFunction && secretResourceArns.length > 0) {
edgeFunction.addToRolePolicy(
new iam.PolicyStatement({
actions: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'],
resources: secretResourceArns,
})
);
}
}
private buildSecretResourceArns(secretId: string): string[] { const stack = Stack.of(this); const baseArn = secretId.startsWith('arn:') ? secretId : stack.formatArn({ service: 'secretsmanager', resource: 'secret', resourceName: secretId, });
const resourceName = baseArn.split(':secret:')[1] ?? secretId;
const normalizedName = resourceName.replace(/-[A-Za-z0-9]{6}$/, '');
const wildcardArn = stack.formatArn({
service: 'secretsmanager',
resource: 'secret',
resourceName: `${normalizedName}*`,
});
return [baseArn, wildcardArn];
}
private restrictFunctionUrlAccess(distribution: cloudfront.Distribution) { if (!this.protectedFunctionUrls.length) { return; }
for (const entry of this.protectedFunctionUrls) {
// Only add IAM-based CloudFront invoke permissions for AWS_IAM URLs.
if (entry.url.authType !== lambda.FunctionUrlAuthType.AWS_IAM) {
continue;
}
entry.url.grantInvokeUrl(
new iam.ServicePrincipal('cloudfront.amazonaws.com', {
conditions: {
ArnLike: {
'aws:SourceArn': distribution.distributionArn,
},
StringEquals: {
'aws:SourceAccount': Stack.of(this).account,
},
},
})
);
}
}
private buildEdgeEnvironment(source: Record<string, string>): Record<string, string> { const rules = resolveEdgeRuntimeEnvRules(this.runtimeEnvironment); return buildEnvironmentFromRules(source, rules); }
private assertEdgeEnvironment(edgeEnv: Record<string, string>) {
const requiredKeys = ['NODE_ENV', 'AWS_REGION', 'CACHE_BUCKET_NAME', 'CACHE_DYNAMO_TABLE'];
const missing = requiredKeys.filter((key) => !edgeEnv[key]);
if (missing.length) {
throw new Error(Edge runtime configuration missing required keys: ${missing.join(', ')});
}
}
private buildLambdaRuntimeEnv(source: Record<string, string>, keys?: string[]): Record<string, string> { const rules = resolveEdgeRuntimeEnvRules(this.runtimeEnvironment); const env = buildEnvironmentFromRules(source, rules); for (const key of [ 'SECRETS_MANAGER_ENV_SECRET_ID', 'SECRETS_MANAGER_REPO_SECRET_ID', 'AWS_SECRETS_MANAGER_PRIMARY_REGION', 'AWS_SECRETS_MANAGER_FALLBACK_REGION', ]) { const value = source[key]; if (value !== undefined) { env[key] = value; } } if (keys && keys.length > 0) { return this.pickRuntimeEnv(env, keys); } return this.filterReservedLambdaEnv(env); }
private buildOriginCustomHeaders(): Record<string, string> { const headers: Record<string, string> = { ...this.edgeRuntimeHeaderValues, ...buildEdgeSecretHeaders(this.runtimeEnvironment), };
const chatOriginSecret =
this.runtimeEnvironment['CHAT_ORIGIN_SECRET'] ?? this.runtimeEnvironment['REVALIDATE_SECRET'];
if (chatOriginSecret) {
headers['x-chat-origin-secret'] = chatOriginSecret;
}
return headers;
}
private toPascalCase(value: string): string { return value .split(/[^A-Za-z0-9]+/) .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(''); } }
