Skip to content

Commit

Permalink
✨ feat(domains): rework how domains are passed, allow for many, auto-…
Browse files Browse the repository at this point in the history
…guess zone (#73, #79)

Allow for passing multiple domains. Get domains from CLI, automatically select the most fitting one.

BREAKING CHANGE: Changed CLI interface, different way to pass domains

#73, #79
  • Loading branch information
sladg committed Feb 25, 2023
1 parent 137c2a5 commit e74a212
Show file tree
Hide file tree
Showing 11 changed files with 182 additions and 116 deletions.
8 changes: 4 additions & 4 deletions lib/cdk/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,21 @@ const RawEnvConfig = cleanEnv(process.env, {
LAMBDA_RUNTIME: str({ default: RuntimeEnum.NODEJS_16_X, choices: Object.values(RuntimeEnum) }),
IMAGE_LAMBDA_TIMEOUT: num({ default: IMAGE_LAMBDA_DEFAULT_TIMEOUT }),
IMAGE_LAMBDA_MEMORY: num({ default: IMAGE_LAMBDA_DEFAULT_MEMORY }),
HOSTED_ZONE: str({ default: undefined }),
DNS_PREFIX: str({ default: undefined }),
CUSTOM_API_DOMAIN: str({ default: undefined }),
REDIRECT_FROM_APEX: bool({ default: false }),
DOMAIN_NAMES: str({ default: undefined }),
PROFILE: str({ default: undefined }),
})

export const envConfig = {
profile: RawEnvConfig.PROFILE,
stackName: RawEnvConfig.STACK_NAME,
lambdaMemory: RawEnvConfig.LAMBDA_MEMORY,
lambdaTimeout: RawEnvConfig.LAMBDA_TIMEOUT,
lambdaRuntime: runtimeMap[RawEnvConfig.LAMBDA_RUNTIME],
imageLambdaMemory: RawEnvConfig.IMAGE_LAMBDA_MEMORY,
imageLambdaTimeout: RawEnvConfig.IMAGE_LAMBDA_TIMEOUT,
hostedZone: RawEnvConfig.HOSTED_ZONE,
dnsPrefix: RawEnvConfig.DNS_PREFIX,
customApiDomain: RawEnvConfig.CUSTOM_API_DOMAIN,
redirectFromApex: RawEnvConfig.REDIRECT_FROM_APEX,
domainNames: RawEnvConfig.DOMAIN_NAMES ? RawEnvConfig.DOMAIN_NAMES.split(',').map((a) => a.trim()) : [],
}
42 changes: 22 additions & 20 deletions lib/cdk/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ import { ICertificate } from 'aws-cdk-lib/aws-certificatemanager'
import { IDistribution } from 'aws-cdk-lib/aws-cloudfront'
import { HttpOrigin } from 'aws-cdk-lib/aws-cloudfront-origins'
import { Function } from 'aws-cdk-lib/aws-lambda'
import { HostedZone, IHostedZone } from 'aws-cdk-lib/aws-route53'
import { Bucket } from 'aws-cdk-lib/aws-s3'
import { CustomStackProps } from './types'
import { CustomStackProps, MappedDomain } from './types'
import { setupApiGateway, SetupApiGwProps } from './utils/apiGw'
import { setupCfnCertificate, SetupCfnCertificateProps } from './utils/cfnCertificate'
import { setupCfnDistro, SetupCfnDistroProps } from './utils/cfnDistro'
import { setupDnsRecords, SetupDnsRecordsProps } from './utils/dnsRecords'
import { PrepareDomainProps, prepareDomains, setupDnsRecords, SetupDnsRecordsProps } from './utils/dnsRecords'
import { setupImageLambda, SetupImageLambdaProps } from './utils/imageLambda'
import { setupApexRedirect, SetupApexRedirectProps } from './utils/redirect'
import { setupAssetsBucket, UploadAssetsProps, uploadStaticAssets } from './utils/s3'
Expand All @@ -23,22 +22,17 @@ export class NextStandaloneStack extends Stack {
assetsBucket?: Bucket
cfnDistro?: IDistribution
cfnCertificate?: ICertificate
hostedZone?: IHostedZone
domainName?: string
domains: MappedDomain[]

constructor(scope: App, id: string, config: CustomStackProps) {
super(scope, id, config)

console.log("CDK's config:", config)

if (config.hostedZone) {
this.hostedZone = HostedZone.fromLookup(this, 'HostedZone_certificate', { domainName: config.hostedZone })
this.domainName = config.dnsPrefix ? `${config.dnsPrefix}.${config.hostedZone}` : config.hostedZone
if (!!config.customApiDomain && config.domainNames.length > 1) {
throw new Error('Cannot use Apex redirect with multiple domains')
}

console.log('Hosted zone:', this.hostedZone?.zoneName)
console.log('Normalized domain name:', this.domainName)

this.assetsBucket = this.setupAssetsBucket()

this.imageLambda = this.setupImageLambda({
Expand Down Expand Up @@ -68,10 +62,16 @@ export class NextStandaloneStack extends Stack {
serverBasePath: config.apigwServerPath,
})

if (!!this.hostedZone && !!this.domainName) {
if (config.domainNames.length > 0) {
this.domains = this.prepareDomains({
domains: config.domainNames,
profile: config.awsProfile,
})
}

if (this.domains.length > 0) {
this.cfnCertificate = this.setupCfnCertificate({
hostedZone: this.hostedZone,
domainName: this.domainName,
domains: this.domains,
})
}

Expand All @@ -80,7 +80,7 @@ export class NextStandaloneStack extends Stack {
apiGateway: this.apiGateway,
imageBasePath: config.apigwImagePath,
serverBasePath: config.apigwServerPath,
domainName: this.domainName,
domains: this.domains,
certificate: this.cfnCertificate,
customApiOrigin: config.customApiDomain ? new HttpOrigin(config.customApiDomain) : undefined,
})
Expand All @@ -91,22 +91,24 @@ export class NextStandaloneStack extends Stack {
cfnDistribution: this.cfnDistro,
})

if (!!this.hostedZone && !!this.domainName) {
if (this.domains.length > 0) {
this.setupDnsRecords({
cfnDistro: this.cfnDistro,
hostedZone: this.hostedZone,
dnsPrefix: config.dnsPrefix,
domains: this.domains,
})

if (!!config.redirectFromApex) {
this.setupApexRedirect({
sourceHostedZone: this.hostedZone,
targetDomain: this.domainName,
domain: this.domains[0],
})
}
}
}

prepareDomains(props: PrepareDomainProps) {
return prepareDomains(this, props)
}

setupAssetsBucket() {
return setupAssetsBucket(this)
}
Expand Down
13 changes: 10 additions & 3 deletions lib/cdk/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { StackProps } from 'aws-cdk-lib'
import { Runtime } from 'aws-cdk-lib/aws-lambda'
import { IHostedZone } from 'aws-cdk-lib/aws-route53'

export interface CustomStackProps extends StackProps {
apigwServerPath: string
Expand All @@ -17,8 +18,14 @@ export interface CustomStackProps extends StackProps {
lambdaRuntime: Runtime
imageLambdaTimeout?: number
imageLambdaMemory?: number
hostedZone?: string
dnsPrefix?: string
domainNames: string[]
redirectFromApex: boolean
awsProfile?: string
customApiDomain?: string
redirectFromApex?: boolean
}

export interface MappedDomain {
recordName: string
domain: string
zone: IHostedZone
}
21 changes: 15 additions & 6 deletions lib/cdk/utils/cfnCertificate.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import { CfnOutput, Stack } from 'aws-cdk-lib'
import { DnsValidatedCertificate } from 'aws-cdk-lib/aws-certificatemanager'
import { IHostedZone } from 'aws-cdk-lib/aws-route53'
import { Certificate, CertificateValidation } from 'aws-cdk-lib/aws-certificatemanager'
import { MappedDomain } from '../types'

export interface SetupCfnCertificateProps {
hostedZone: IHostedZone
domainName: string
domains: MappedDomain[]
}

export const setupCfnCertificate = (scope: Stack, { hostedZone, domainName }: SetupCfnCertificateProps) => {
export const setupCfnCertificate = (scope: Stack, { domains }: SetupCfnCertificateProps) => {
const [firstDomain, ...otherDomains] = domains

// us-east-1 is needed for Cloudfront to accept certificate.
const certificate = new DnsValidatedCertificate(scope, 'Certificate', { domainName, hostedZone, region: 'us-east-1' })
// https://github.com/aws/aws-cdk/issues/8934
const multiZoneMap = domains.reduce((acc, curr) => ({ ...acc, [curr.domain]: curr.zone }), {})

const certificate = new Certificate(scope, 'Certificate', {
domainName: firstDomain.domain,

subjectAlternativeNames: otherDomains.map((a) => a.domain),
validation: CertificateValidation.fromDnsMultiZone(multiZoneMap),
})

new CfnOutput(scope, 'certificateArn', { value: certificate.certificateArn })

Expand Down
7 changes: 4 additions & 3 deletions lib/cdk/utils/cfnDistro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import {
} from 'aws-cdk-lib/aws-cloudfront'
import { HttpOrigin, S3Origin } from 'aws-cdk-lib/aws-cloudfront-origins'
import { Bucket } from 'aws-cdk-lib/aws-s3'
import { MappedDomain } from '../types'

export interface SetupCfnDistroProps {
domainName?: string
domains: MappedDomain[]
certificate?: ICertificate
apiGateway: HttpApi
imageBasePath: string
Expand All @@ -27,7 +28,7 @@ export interface SetupCfnDistroProps {

export const setupCfnDistro = (
scope: Stack,
{ apiGateway, imageBasePath, serverBasePath, assetsBucket, domainName, certificate, customApiOrigin }: SetupCfnDistroProps,
{ apiGateway, imageBasePath, serverBasePath, assetsBucket, domains, certificate, customApiOrigin }: SetupCfnDistroProps,
) => {
const apiGwDomainName = `${apiGateway.apiId}.execute-api.${scope.region}.amazonaws.com`

Expand Down Expand Up @@ -75,7 +76,7 @@ export const setupCfnDistro = (
comment: `CloudFront distribution for ${scope.stackName}`,
enableIpv6: true,
priceClass: PriceClass.PRICE_CLASS_100,
domainNames: domainName ? [domainName] : undefined,
domainNames: domains.length > 0 ? domains.map((a) => a.domain) : undefined,
certificate,
defaultBehavior: {
origin: serverOrigin,
Expand Down
70 changes: 62 additions & 8 deletions lib/cdk/utils/dnsRecords.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,74 @@
import { CfnOutput, Stack } from 'aws-cdk-lib'
import { IDistribution } from 'aws-cdk-lib/aws-cloudfront'
import { AaaaRecord, ARecord, IHostedZone, RecordTarget } from 'aws-cdk-lib/aws-route53'
import { AaaaRecord, ARecord, HostedZone, RecordTarget } from 'aws-cdk-lib/aws-route53'
import { CloudFrontTarget } from 'aws-cdk-lib/aws-route53-targets'
import { execSync } from 'child_process'
import { MappedDomain } from '../types'
import { readFileSync } from 'fs'
import { tmpdir } from 'os'
import path from 'path'

export interface PrepareDomainProps {
domains: string[]
profile?: string
}

export interface SetupDnsRecordsProps {
dnsPrefix?: string
hostedZone: IHostedZone
domains: MappedDomain[]
cfnDistro: IDistribution
}

export const setupDnsRecords = (scope: Stack, { dnsPrefix: recordName, hostedZone: zone, cfnDistro }: SetupDnsRecordsProps) => {
// AWS-CDK does not have a way to retrieve the hosted zones in given account, so we need to go around.
const getAvailableHostedZones = (profile?: string): string[] => {
const tmpDir = path.join(tmpdir(), 'hosted-zones.json')
const profileFlag = profile ? `--profile ${profile}` : ''
execSync(`aws route53 list-hosted-zones --output json ${profileFlag} > ${tmpDir}`)
const output = JSON.parse(readFileSync(tmpDir, 'utf8'))
return output.HostedZones.map((zone: any) => zone.Name)
}

const matchDomainToHostedZone = (domainToMatch: string, zones: string[]) => {
const matchedZone = zones.reduce((acc, curr) => {
const matchRegex = new RegExp(`(.*)${curr}$`)

const isMatching = !!`${domainToMatch}.`.match(matchRegex)
const isMoreSpecific = curr.split('.').length > (acc?.split('.').length ?? 0)

if (isMatching && isMoreSpecific) {
return curr
} else {
return acc
}
}, null as string | null)

if (!matchedZone) {
throw new Error(`No hosted zone found for domain: ${domainToMatch}`)
}

return matchedZone.replace('/.$/', '')
}

export const prepareDomains = (scope: Stack, { domains, profile }: PrepareDomainProps): MappedDomain[] => {
const zones = getAvailableHostedZones(profile)

return domains.map((domain, index) => {
const hostedZone = matchDomainToHostedZone(domain, zones)
const recordName = domain.replace(hostedZone, '')

const zone = HostedZone.fromLookup(scope, `Zone_${index}`, { domainName: hostedZone })

return { zone, recordName, domain }
})
}

export const setupDnsRecords = (scope: Stack, { domains, cfnDistro }: SetupDnsRecordsProps) => {
const target = RecordTarget.fromAlias(new CloudFrontTarget(cfnDistro))

const dnsARecord = new ARecord(scope, 'AAliasRecord', { recordName, target, zone })
const dnsAaaaRecord = new AaaaRecord(scope, 'AaaaAliasRecord', { recordName, target, zone })
domains.forEach(({ recordName, zone }, index) => {
const dnsARecord = new ARecord(scope, `AAliasRecord_${index}`, { recordName, target, zone })
const dnsAaaaRecord = new AaaaRecord(scope, `AaaaAliasRecord_${index}`, { recordName, target, zone })

new CfnOutput(scope, 'dns_A_Record', { value: dnsARecord.domainName })
new CfnOutput(scope, 'dns_AAAA_Record', { value: dnsAaaaRecord.domainName })
new CfnOutput(scope, `dns_A_Record_${index}`, { value: dnsARecord.domainName })
new CfnOutput(scope, `dns_AAAA_Record_${index}`, { value: dnsAaaaRecord.domainName })
})
}
13 changes: 6 additions & 7 deletions lib/cdk/utils/redirect.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
import { CfnOutput, Stack } from 'aws-cdk-lib'
import { IHostedZone } from 'aws-cdk-lib/aws-route53'
import { HttpsRedirect } from 'aws-cdk-lib/aws-route53-patterns'
import { MappedDomain } from '../types'

export interface SetupApexRedirectProps {
sourceHostedZone: IHostedZone
targetDomain: string
domain: MappedDomain
}

export const setupApexRedirect = (scope: Stack, { sourceHostedZone, targetDomain }: SetupApexRedirectProps) => {
export const setupApexRedirect = (scope: Stack, { domain }: SetupApexRedirectProps) => {
new HttpsRedirect(scope, `ApexRedirect`, {
// Currently supports only apex (root) domain.
zone: sourceHostedZone,
targetDomain,
zone: domain.zone,
targetDomain: domain.recordName,
})

new CfnOutput(scope, 'RedirectFrom', { value: sourceHostedZone.zoneName })
new CfnOutput(scope, 'RedirectFrom', { value: domain.zone.zoneName })
}
9 changes: 3 additions & 6 deletions lib/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@ program
.option('--imageLambdaTimeout <sec>', 'Set timeout for lambda function handling image optimization.', Number, IMAGE_LAMBDA_DEFAULT_TIMEOUT)
.option('--imageLambdaMemory <mb>', 'Set memory for lambda function handling image optimization.', Number, IMAGE_LAMBDA_DEFAULT_MEMORY)
.option('--lambdaRuntime <runtime>', "Specify version of NodeJS to use as Lambda's runtime. Options: node14, node16, node18.", 'node16')
.option('--hostedZone <domainName>', 'Hosted zone domain name to be used for creating DNS records (example: example.com).', undefined)
.option('--domainNamePrefix <prefix>', 'Prefix for creating DNS records, if left undefined, hostedZone will be used (example: app).', undefined)
.option('--domains <domainList>', 'Comma-separated list of domains to use. (example: mydomain.com,mydonain.au,other.domain.com)', undefined)
.option('--customApiDomain <domain>', 'Domain to forward the requests to /api routes, by default API routes will be handled by the server lambda.', undefined)
.option('--redirectFromApex', 'Redirect from apex domain to specified address.', false)
.option('--profile <name>', 'AWS profile to use with CDK.', undefined)
Expand All @@ -78,10 +77,9 @@ program
lambdaRuntime,
imageLambdaMemory,
imageLambdaTimeout,
hostedZone,
domainNamePrefix,
customApiDomain,
redirectFromApex,
domains,
hotswap,
profile,
} = options
Expand All @@ -97,10 +95,9 @@ program
lambdaRuntime,
imageLambdaMemory,
imageLambdaTimeout,
hostedZone,
domainNamePrefix,
customApiDomain,
redirectFromApex,
domains,
hotswap,
profile,
}),
Expand Down
9 changes: 3 additions & 6 deletions lib/cli/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ interface Props {
imageLambdaMemory?: number
imageLambdaTimeout?: number
customApiDomain?: string
hostedZone?: string
domainNamePrefix?: string
domains?: string
redirectFromApex?: boolean
profile?: string
hotswap: boolean
Expand All @@ -30,8 +29,7 @@ export const deployHandler = async ({
lambdaRuntime,
imageLambdaMemory,
imageLambdaTimeout,
domainNamePrefix,
hostedZone,
domains,
customApiDomain,
redirectFromApex,
hotswap,
Expand All @@ -58,8 +56,7 @@ export const deployHandler = async ({
...(lambdaRuntime && { LAMBDA_RUNTIME: lambdaRuntime.toString() }),
...(imageLambdaMemory && { IMAGE_LAMBDA_MEMORY: imageLambdaMemory.toString() }),
...(imageLambdaTimeout && { IMAGE_LAMBDA_TIMEOUT: imageLambdaTimeout.toString() }),
...(hostedZone && { HOSTED_ZONE: hostedZone }),
...(domainNamePrefix && { DNS_PREFIX: domainNamePrefix }),
...(domains && { DOMAINS: domains }),
...(customApiDomain && { CUSTOM_API_DOMAIN: customApiDomain }),
...(redirectFromApex && { REDIRECT_FROM_APEX: redirectFromApex.toString() }),
}
Expand Down
Loading

0 comments on commit e74a212

Please sign in to comment.