Skip to content

Commit

Permalink
feat: support no custom domain/dns/certs (#305)
Browse files Browse the repository at this point in the history
* support no custom domain/dns/certs

* export website endpoint

* fix handling of optional props

* fix domain name ref

* chore: self mutation

Signed-off-by: github-actions <github-actions@github.com>

* add test cases for onlyDefaultDomain props

* remove unused

* chore: test when prop is true

---------

Signed-off-by: github-actions <github-actions@github.com>
Co-authored-by: John Long <john.long@kikoda.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Nathan Cazell <nathan.cazell@kikoda.com>
  • Loading branch information
4 people committed May 26, 2023
1 parent 5ad8f6f commit c108ce6
Show file tree
Hide file tree
Showing 5 changed files with 270 additions and 87 deletions.
52 changes: 38 additions & 14 deletions API.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

124 changes: 75 additions & 49 deletions src/single-page-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,26 +29,35 @@ import minimatch = require('minimatch');

export interface SinglePageAppProps {
/**
* Provide an existing Hosted Zone to use for the domain.
* Provide an existing Hosted Zone to use for the domain. This property is required unless `onlyDefaultDomain` is `true`, in which case it will be ignored.
*/
readonly hostedZone: IHostedZone;
readonly hostedZone?: IHostedZone;

/**
* The domain name to use for the SPA
* The domain name to use for the SPA. This property is required unless `onlyDefaultDomain` is `true`, in which case it will be ignored.
*/
readonly domainName: string;
readonly domainName?: string;

/**
* Specify alternate domain names to use for the Cloudfront Distribution.
* Specify alternate domain names to use for the Cloudfront Distribution. This property will be ignored if `onlyDefaultDomain` is `true`.
*/
readonly alternateDomainNames?: string[];

/**
* Specify an ARN of an ACM certificate to use for the Cloudfront Distribution. This is
* useful when you are deploying to a region other than `us-east-1`, as Cloudfront
* requires the certificate to be in `us-east-1`.
* requires the certificate to be in `us-east-1`. This property will be ignored if `onlyDefaultDomain` is `true`.
*/
readonly acmCertificateArn?: string;

/**
* Do not create or look up a hosted zone or certificates for the website. The website will be served under the default CloudFront domain only.
* Setting this to `true` will ignore the values set for `acmCertificateArn`, `domainName`, `alternateDomainNames`, and `hostedZone`.
*
* @default false
*/
readonly onlyDefaultDomain?: boolean;

readonly indexDoc: string;
readonly errorDoc?: string;

Expand Down Expand Up @@ -95,41 +104,54 @@ export class SinglePageApp extends Construct {
constructor(scope: Construct, id: string, props: SinglePageAppProps) {
super(scope, id);

// Resolve the ACM certificate if provided or create one
let certificate: ICertificate;
if (props.acmCertificateArn) {
const certificateRegion = Stack.of(this).splitArn(
props.acmCertificateArn,
ArnFormat.SLASH_RESOURCE_NAME,
).region;
// check domain props
if (
!props.onlyDefaultDomain &&
(props.domainName === undefined || props.hostedZone === undefined)
) {
throw new Error(
`domainName and hostedZone must be provided if onlyDefaultDomain is not true`,
);
}

let certificate: ICertificate | undefined = undefined;

if (!props.onlyDefaultDomain) {
// Resolve the ACM certificate if provided or create one
if (props.acmCertificateArn) {
const certificateRegion = Stack.of(this).splitArn(
props.acmCertificateArn,
ArnFormat.SLASH_RESOURCE_NAME,
).region;

if (!Token.isUnresolved(certificateRegion) && certificateRegion !== 'us-east-1') {
if (!Token.isUnresolved(certificateRegion) && certificateRegion !== 'us-east-1') {
throw new Error(
`The certificate must be in the us-east-1 region and the certificate you provided is in ${certificateRegion}.`,
);
}

certificate = Certificate.fromCertificateArn(this, 'Certificate', props.acmCertificateArn);
}
// Create a new certificate only if all the domain names are in the same hosted zone
else if (
props.alternateDomainNames?.every(domainName =>
domainName.endsWith(props.hostedZone!.zoneName),
) ||
!props.alternateDomainNames
) {
certificate = new DnsValidatedCertificate(this, 'Certificate', {
region: 'us-east-1',
hostedZone: props.hostedZone!,
domainName: props.domainName!,
subjectAlternativeNames: props.alternateDomainNames,
});
}
// If the domain names are not in the same hosted zone and no certArn was provided, throw an error
else {
throw new Error(
`The certificate must be in the us-east-1 region and the certificate you provided is in ${certificateRegion}.`,
'The alternate domain names must be in the same hosted zone as the domain name or you must provide an ACM certificate with the acmCertificateArn prop.',
);
}

certificate = Certificate.fromCertificateArn(this, 'Certificate', props.acmCertificateArn);
}
// Create a new certificate only if all the domain names are in the same hosted zone
else if (
props.alternateDomainNames?.every(domainName =>
domainName.endsWith(props.hostedZone.zoneName),
) ||
!props.alternateDomainNames
) {
certificate = new DnsValidatedCertificate(this, 'Certificate', {
region: 'us-east-1',
hostedZone: props.hostedZone,
domainName: props.domainName,
subjectAlternativeNames: props.alternateDomainNames,
});
}
// If the domain names are not in the same hosted zone and no certArn was provided, throw an error
else {
throw new Error(
'The alternate domain names must be in the same hosted zone as the domain name or you must provide an ACM certificate with the acmCertificateArn prop.',
);
}

this.websiteBucket = new Bucket(this, 'WebsiteBucket', {
Expand Down Expand Up @@ -162,7 +184,9 @@ export class SinglePageApp extends Construct {
responseHttpStatus: 200,
},
],
domainNames: [props.domainName, ...(props.alternateDomainNames ?? [])],
domainNames: props.onlyDefaultDomain
? undefined
: [props.domainName!, ...(props.alternateDomainNames ?? [])],
certificate,
minimumProtocolVersion: props.securityPolicy ?? SecurityPolicyProtocol.TLS_V1_2_2021,
});
Expand Down Expand Up @@ -235,17 +259,19 @@ export class SinglePageApp extends Construct {
distributionPaths: props.cloudfrontInvalidationPaths,
});

// Create an ALIAS record for all the specified domain names
[props.domainName, ...(props.alternateDomainNames || [])].forEach(domainName => {
// only create the record if it's in the provided hosted zone
if (domainName.endsWith(props.hostedZone.zoneName)) {
new ARecord(this, `Alias-${domainName}`, {
zone: props.hostedZone,
recordName: domainName,
target: RecordTarget.fromAlias(new CloudFrontTarget(this.distribution)),
});
}
});
if (!props.onlyDefaultDomain) {
// Create an ALIAS record for all the specified domain names
[props.domainName!, ...(props.alternateDomainNames || [])].forEach(domainName => {
// only create the record if it's in the provided hosted zone
if (domainName.endsWith(props.hostedZone!.zoneName)) {
new ARecord(this, `Alias-${domainName}`, {
zone: props.hostedZone!,
recordName: domainName,
target: RecordTarget.fromAlias(new CloudFrontTarget(this.distribution)),
});
}
});
}
}

/**
Expand Down
59 changes: 35 additions & 24 deletions src/website.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,32 +71,41 @@ export interface WebsiteProps {
readonly generateWebConfigProps?: GenerateWebConfigProps;

/**
* Specify a domain name to use for the website.
* Specify a domain name to use for the website. This property is required unless `onlyDefaultDomain` is `true`, in which case it will be ignored.
*/
readonly domainName: string;
readonly domainName?: string;

/**
* Specify alternate domain names to use for the website. An Alias record will
* only be created if the alternate domain name is in the provided hosted zone.
* If you need to use a different hosted zone, consider using the `acmCertificateArn`
* option instead to provide a certificate with the alternate domain names.
* This property will be ignored if `onlyDefaultDomain` is `true`.
*
* @default - No alternate domain names
*/
readonly alternateDomainNames?: string[];

/**
* Provide an ACM certificate ARN to use for the website.
* Provide an ACM certificate ARN to use for the website. This property will be ignored if `onlyDefaultDomain` is `true`.
*/
readonly acmCertificateArn?: string;

/**
* Specify an existing hosted zone to use for the website.
* Specify an existing hosted zone to use for the website. This property will be ignored if `onlyDefaultDomain` is `true`.
*
* @default - This construct will try to lookup an existing hosted zone for the domain name provided.
* @default - This construct will try to lookup an existing hosted zone for the domain name provided, unless `onlyDefaultDomain` is `true`.
*/
readonly hostedZone?: IHostedZone;

/**
* Do not create or look up a hosted zone or certificates for the website. The website will be served under the default CloudFront domain only.
* Setting this to `true` will ignore the values set for `acmCertificateArn`, `domainName`, `alternateDomainNames`, and `hostedZone`.
*
* @default false
*/
readonly onlyDefaultDomain?: boolean;

/** Setup S3 bucket and Cloudfront distribution to allow CORS requests. Optionally specificy the allowed Origins with `corsAllowedOrigins` */
readonly enableCors?: boolean;

Expand All @@ -123,28 +132,23 @@ export class Website extends Construct {
constructor(scope: Construct, id: string, props: WebsiteProps) {
super(scope, id);

const {
stage,
domainName,
alternateDomainNames,
acmCertificateArn,
hostedZone,
appDir,
buildDir,
indexDoc,
generateWebConfigProps,
bundling,
buildCommand,
} = props;
const { stage, appDir, buildDir, indexDoc, generateWebConfigProps, bundling, buildCommand } =
props;

// export endpoint
this.endpoint = `https://${domainName}`;
// check domain props
if (!props.onlyDefaultDomain && props.domainName === undefined) {
throw new Error(`domainName must be provided if onlyDefaultDomain is not true`);
}

const spa = new SinglePageApp(this, 'Spa', {
hostedZone: hostedZone ?? HostedZone.fromLookup(this, 'HostedZone', { domainName }),
domainName,
alternateDomainNames,
acmCertificateArn,
hostedZone: props.onlyDefaultDomain
? undefined
: props.hostedZone ??
HostedZone.fromLookup(this, 'HostedZone', { domainName: props.domainName! }),
domainName: props.onlyDefaultDomain ? undefined : props.domainName,
alternateDomainNames: props.onlyDefaultDomain ? undefined : props.alternateDomainNames,
acmCertificateArn: props.onlyDefaultDomain ? undefined : props.acmCertificateArn,
onlyDefaultDomain: props.onlyDefaultDomain,
appDir,
buildDir,
buildAssetExcludes: [
Expand Down Expand Up @@ -173,6 +177,13 @@ export class Website extends Construct {
: undefined,
});

// export endpoint
if (props.onlyDefaultDomain) {
this.endpoint = `https://${spa.distribution.distributionDomainName}`;
} else {
this.endpoint = `https://${props.domainName}`;
}

// create frontend config file asset
if (!!generateWebConfigProps) {
// generate dynamic config
Expand Down
Loading

0 comments on commit c108ce6

Please sign in to comment.