aws-spa
Version:
A no-brainer script to deploy a single page app on AWS
582 lines (482 loc) • 19.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.updateCloudFrontDistribution = exports.tagCloudFrontDistribution = exports.invalidateCloudfrontCacheWithRetry = exports.invalidateCloudfrontCache = exports.identifyingTag = exports.getCacheInvalidations = exports.findDeployedCloudfrontDistribution = exports.createCloudFrontDistribution = exports.add403RedirectionToRoot = void 0;
var _clientCloudfront = require("@aws-sdk/client-cloudfront");
var _awsHelper = require("../aws-helper");
var _awsServices = require("../aws-services");
var _logger = require("../logger");
var _constants = require("./constants");
var _noDefaultRootObjectFunction = require("./noDefaultRootObjectFunction");
function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
const findDeployedCloudfrontDistribution = async domainName => {
const distributions = await (0, _awsHelper.getAll)(async (nextMarker, page) => {
_logger.logger.info(`[CloudFront] 🔍 Searching cloudfront distribution (page ${page})...`);
const {
DistributionList
} = await _awsServices.cloudfront.listDistributions({
Marker: nextMarker
});
if (!DistributionList) {
return {
items: [],
nextMarker: undefined
};
}
return {
items: DistributionList.Items || [],
nextMarker: DistributionList.NextMarker
};
});
const distribution = distributions.find(_distribution => Boolean(_distribution.Aliases?.Items && _distribution.Aliases.Items.includes(domainName)));
if (!distribution) {
_logger.logger.info(`[CloudFront] 😬 No matching distribution`);
return null;
}
if (!distribution.Id) {
throw new Error('[CloudFront] Distribution has no ID');
}
const {
Tags
} = await _awsServices.cloudfront.listTagsForResource({
Resource: distribution.ARN
});
if (!Tags || !Tags.Items || !Tags.Items.find(tag => tag.Key === identifyingTag.Key && tag.Value === identifyingTag.Value)) {
throw new Error(`CloudFront distribution ${distribution.Id} has no tag ${identifyingTag.Key}:${identifyingTag.Value}`);
}
_logger.logger.info(`[CloudFront] 👍 Distribution found: ${distribution.Id}`);
if (!distribution.Status) {
throw new Error(`[CloudFront] Distribution ${distribution.Id} has no status`);
}
if (['InProgress', 'In Progress'].includes(distribution.Status)) {
_logger.logger.info(`[CloudFront] ⏱ Waiting for distribution to be deployed. This step might takes up to 25 minutes...`);
await _awsServices.waitUntil.distributionDeployed({
client: _awsServices.cloudfront,
maxWaitTime: 1500
}, {
Id: distribution.Id
});
_logger.logger.info(`[CloudFront] ✅ Distribution deployed: ${distribution.Id}`);
}
_logger.logger.info(`[CloudFront] ✅ Using distribution: ${distribution.Id}`);
return _objectSpread(_objectSpread({}, distribution), {}, {
Id: distribution.Id,
ARN: distribution.ARN,
DomainName: distribution.DomainName
});
};
exports.findDeployedCloudfrontDistribution = findDeployedCloudfrontDistribution;
const tagCloudFrontDistribution = async distribution => {
_logger.logger.info(`[CloudFront] ✏️ Tagging "${distribution.Id}" bucket with "${identifyingTag.Key}:${identifyingTag.Value}"...`);
await _awsServices.cloudfront.tagResource({
Resource: distribution.ARN,
Tags: {
Items: [identifyingTag]
}
});
};
exports.tagCloudFrontDistribution = tagCloudFrontDistribution;
const createCloudFrontDistribution = async (domainName, sslCertificateARN) => {
_logger.logger.info(`[CloudFront] ✏️ Creating Cloudfront distribution with origin "${(0, _awsServices.getS3DomainName)(domainName)}"...`);
const {
Distribution
} = await _awsServices.cloudfront.createDistribution({
DistributionConfig: getBaseDistributionConfig(domainName, sslCertificateARN)
});
if (!Distribution) {
throw new Error('[CloudFront] Could not create distribution');
}
if (!Distribution.Id) {
throw new Error('[CloudFront] Distribution has no ID');
}
await tagCloudFrontDistribution(Distribution);
_logger.logger.info(`[CloudFront] ⏱ Waiting for distribution to be available. This step might takes up to 25 minutes...`);
await _awsServices.waitUntil.distributionDeployed({
client: _awsServices.cloudfront,
maxWaitTime: 1500
}, {
Id: Distribution.Id
});
_logger.logger.info(`[CloudFront] ✅ Distribution deployed: ${Distribution.Id}`);
return _objectSpread(_objectSpread({}, Distribution), {}, {
Id: Distribution.Id,
ARN: Distribution.ARN,
DomainName: Distribution.DomainName
});
};
exports.createCloudFrontDistribution = createCloudFrontDistribution;
const createAndPublishNoDefaultRootObjectRedirectionFunction = async () => {
const cloudFrontRedirectionFunctionName = _constants.NO_DEFAULT_ROOT_OBJECT_REDIRECTION_FUNCTION_NAME + '_' + _constants.NO_DEFAULT_ROOT_OBJECT_REDIRECTION_COLOR;
const currentFunctionARN = await isCloudFrontFunctionExisting(cloudFrontRedirectionFunctionName);
if (!currentFunctionARN) {
const {
createdFunctionETag,
createdFunctionARN
} = await createNoDefaultRootObjectFunction(cloudFrontRedirectionFunctionName);
if (!createdFunctionARN) {
throw new Error(`[CloudFront] Could not create function to handle redirection when no default root object. No ARN returned.`);
}
if (!createdFunctionETag) {
throw new Error(`[CloudFront] Could not create function to handle redirection when no default root object. No Etag returned.`);
}
await publishCloudFrontFunction(cloudFrontRedirectionFunctionName, createdFunctionETag);
return createdFunctionARN;
}
return currentFunctionARN;
};
const isCloudFrontFunctionExisting = async name => {
try {
const {
FunctionList
} = await _awsServices.cloudfront.listFunctions();
const existingFunctionARN = FunctionList?.Items?.find(item => item.Name === name)?.FunctionMetadata?.FunctionARN;
return existingFunctionARN;
} catch (error) {
throw new Error(`[CloudFront] Error listing functions: ${error}`);
}
};
const createNoDefaultRootObjectFunction = async functionName => {
_logger.logger.info(`[CloudFront] ✏️ Creating function to handle redirection when no default root object...`);
let createdFunctionETag;
let createdFunctionARN;
try {
const data = await _awsServices.cloudfront.createFunction({
Name: functionName,
FunctionCode: new TextEncoder().encode(_noDefaultRootObjectFunction.noDefaultRootObjectFunctions[_constants.NO_DEFAULT_ROOT_OBJECT_REDIRECTION_COLOR]),
FunctionConfig: {
Runtime: 'cloudfront-js-2.0',
Comment: 'Redirects to branch specific index.html when no default root object is set'
}
});
createdFunctionARN = data.FunctionSummary?.FunctionMetadata?.FunctionARN;
createdFunctionETag = data.ETag;
return {
createdFunctionETag,
createdFunctionARN
};
} catch (error) {
throw new Error(`[CloudFront] Error creating function: ${error}`);
}
};
const publishCloudFrontFunction = async (name, etag) => {
_logger.logger.info(`[CloudFront] ✏️ Publish function to handle redirection when no default root object...`);
await _awsServices.cloudfront.publishFunction({
Name: name,
IfMatch: etag
}, err => {
if (err) {
throw new Error(`[CloudFront] Error publishing function: ${err}`);
}
});
};
const getBaseDistributionConfig = (domainName, sslCertificateARN) => ({
CallerReference: Date.now().toString(),
Aliases: {
Quantity: 1,
Items: [domainName]
},
Origins: {
Quantity: 1,
Items: [{
Id: (0, _awsServices.getOriginId)(domainName),
DomainName: (0, _awsServices.getS3DomainName)(domainName),
CustomOriginConfig: {
HTTPPort: 80,
HTTPSPort: 443,
OriginProtocolPolicy: 'http-only',
OriginSslProtocols: {
Quantity: 1,
Items: ['TLSv1']
},
OriginReadTimeout: 30,
OriginKeepaliveTimeout: 5
},
CustomHeaders: {
Quantity: 0,
Items: []
},
OriginPath: ''
}]
},
Enabled: true,
Comment: '',
PriceClass: 'PriceClass_All',
Logging: {
Enabled: false,
IncludeCookies: false,
Bucket: '',
Prefix: ''
},
CacheBehaviors: {
Quantity: 0
},
CustomErrorResponses: {
Quantity: 0
},
Restrictions: {
GeoRestriction: {
RestrictionType: 'none',
Quantity: 0
}
},
DefaultRootObject: _constants.DEFAULT_ROOT_OBJECT,
WebACLId: '',
HttpVersion: 'http2',
DefaultCacheBehavior: {
ViewerProtocolPolicy: 'redirect-to-https',
TargetOriginId: (0, _awsServices.getOriginId)(domainName),
ForwardedValues: {
QueryString: false,
Cookies: {
Forward: 'none'
},
Headers: {
Quantity: 0,
Items: []
},
QueryStringCacheKeys: {
Quantity: 0,
Items: []
}
},
AllowedMethods: {
Quantity: 2,
Items: ['HEAD', 'GET'],
CachedMethods: {
Quantity: 2,
Items: ['HEAD', 'GET']
}
},
TrustedSigners: {
Enabled: false,
Quantity: 0
},
MinTTL: 0,
DefaultTTL: 86400,
MaxTTL: 31536000,
FieldLevelEncryptionId: '',
LambdaFunctionAssociations: {
Quantity: 0,
Items: []
},
SmoothStreaming: false,
Compress: true // this is required to deliver gzip data
},
ViewerCertificate: {
ACMCertificateArn: sslCertificateARN,
SSLSupportMethod: 'sni-only',
MinimumProtocolVersion: 'TLSv1.1_2016',
CertificateSource: 'acm'
}
});
const invalidateCloudfrontCache = async (distributionId, paths, wait = false) => {
_logger.logger.info('[CloudFront] ✏️ Creating invalidation...');
const {
Invalidation
} = await _awsServices.cloudfront.createInvalidation({
DistributionId: distributionId,
InvalidationBatch: {
CallerReference: Date.now().toString(),
Paths: {
Quantity: paths.split(',').length,
Items: paths.split(',').map(path => path.trim())
}
}
});
if (!Invalidation) {
return;
}
if (wait) {
_logger.logger.info('[CloudFront] ⏱ Waiting for invalidation to be completed (can take up to 10 minutes)...');
await _awsServices.waitUntil.invalidationCompleted({
client: _awsServices.cloudfront,
maxWaitTime: 600
}, {
DistributionId: distributionId,
Id: Invalidation.Id
});
_logger.logger.info('[CloudFront] ✅ Invalidation completed');
}
};
exports.invalidateCloudfrontCache = invalidateCloudfrontCache;
const invalidateCloudfrontCacheWithRetry = async (distributionId, paths, wait = false, count = 0) => {
try {
return await invalidateCloudfrontCache(distributionId, paths, wait);
} catch (error) {
if (count < 4) {
return await invalidateCloudfrontCacheWithRetry(distributionId, paths, wait, count + 1);
}
throw error;
}
};
exports.invalidateCloudfrontCacheWithRetry = invalidateCloudfrontCacheWithRetry;
const identifyingTag = {
Key: 'managed-by-aws-spa',
Value: 'v1'
};
exports.identifyingTag = identifyingTag;
const getCacheInvalidations = (cacheInvalidations, subFolder) => cacheInvalidations.split(',').map(string => string.trim().replace(/^\//, '')).map(string => subFolder ? `/${subFolder}/${string}` : `/${string}`).join(',');
exports.getCacheInvalidations = getCacheInvalidations;
const updateCloudFrontDistribution = async (distributionId, domainName, options) => {
const {
shouldBlockBucketPublicAccess,
oac,
noDefaultRootObject,
redirect403ToRoot
} = options;
try {
let functionARN;
let updatedDistributionConfig;
const {
DistributionConfig,
ETag
} = await _awsServices.cloudfront.getDistributionConfig({
Id: distributionId
});
if (!DistributionConfig) {
throw new Error(`[Cloudfront] No distribution config found for distribution "${distributionId}"`);
}
if (noDefaultRootObject) {
functionARN = await createAndPublishNoDefaultRootObjectRedirectionFunction();
updatedDistributionConfig = addFunctionToDistribution(DistributionConfig, functionARN);
} else {
updatedDistributionConfig = ensureFunctionIsNotAssociated(DistributionConfig);
}
_logger.logger.info(`[Cloudfront] ✏️ Update distribution configuration "${distributionId}"...`);
if (shouldBlockBucketPublicAccess) {
if (!oac?.originAccessControl?.Id) {
throw new Error(`[Cloudfront] No origin access control found for distribution "${distributionId}"`);
}
updatedDistributionConfig = makeBucketPrivate(domainName, updatedDistributionConfig, oac.originAccessControl.Id);
} else {
updatedDistributionConfig = makeBucketPublic(updatedDistributionConfig, domainName);
}
if (redirect403ToRoot) {
updatedDistributionConfig = add403RedirectionToRoot(updatedDistributionConfig);
}
const shouldUpdateDistribution = isDistributionConfigModified(DistributionConfig, updatedDistributionConfig);
if (!shouldUpdateDistribution) {
_logger.logger.info(`[Cloudfront] 👍 No updates needed for distribution "${distributionId}"`);
return;
}
await _awsServices.cloudfront.updateDistribution({
Id: distributionId,
IfMatch: ETag,
DistributionConfig: updatedDistributionConfig
});
} catch (error) {
throw error;
}
};
exports.updateCloudFrontDistribution = updateCloudFrontDistribution;
const isDistributionConfigModified = (updatedDistributionConfig, distributionConfig) => JSON.stringify(updatedDistributionConfig) !== JSON.stringify(distributionConfig);
const addFunctionToDistribution = (distributionConfig, functionARN) => _objectSpread(_objectSpread({}, distributionConfig), {}, {
DefaultRootObject: '',
DefaultCacheBehavior: _objectSpread(_objectSpread({}, distributionConfig.DefaultCacheBehavior), {}, {
FunctionAssociations: {
Quantity: 1,
Items: [{
FunctionARN: functionARN,
EventType: 'viewer-request'
}]
}
})
});
const ensureFunctionIsNotAssociated = distributionConfig => {
const configWithoutFunctions = _objectSpread(_objectSpread({}, distributionConfig), {}, {
DefaultRootObject: _constants.DEFAULT_ROOT_OBJECT
});
delete configWithoutFunctions.DefaultCacheBehavior?.FunctionAssociations;
return configWithoutFunctions;
};
const makeBucketPrivate = (domainName, distributionConfig, originAccessControlId) => {
const privateBucketDomainName = (0, _awsServices.getS3DomainNameForBlockedBucket)(domainName);
const isOACAlreadyAssociated = distributionConfig?.Origins?.Items?.find(o => o.DomainName === privateBucketDomainName);
if (isOACAlreadyAssociated) {
_logger.logger.info(`[Cloudfront] 👍 OAC already associated with S3 domain "${privateBucketDomainName}"...`);
return distributionConfig;
}
_logger.logger.info(`[Cloudfront] ✏️ Generating new OAC association config for S3 domain "${privateBucketDomainName}"...`);
return _objectSpread(_objectSpread({}, distributionConfig), {}, {
Origins: _objectSpread(_objectSpread({}, distributionConfig.Origins), {}, {
Quantity: 1,
Items: [{
Id: privateBucketDomainName,
DomainName: privateBucketDomainName,
OriginAccessControlId: originAccessControlId,
S3OriginConfig: {
OriginAccessIdentity: '' // If you're using origin access control (OAC) instead of origin access identity, specify an empty OriginAccessIdentity element
},
OriginPath: '',
CustomHeaders: {
Quantity: 0,
Items: []
}
}]
}),
DefaultCacheBehavior: _objectSpread(_objectSpread({}, distributionConfig.DefaultCacheBehavior), {}, {
ViewerProtocolPolicy: distributionConfig.DefaultCacheBehavior?.ViewerProtocolPolicy || 'redirect-to-https',
TargetOriginId: privateBucketDomainName
})
});
};
const makeBucketPublic = (distributionConfig, domainName) => {
const isS3WebsiteAlreadyAssociated = distributionConfig?.Origins?.Items?.find(o => o.DomainName === (0, _awsServices.getS3DomainName)(domainName));
if (isS3WebsiteAlreadyAssociated) {
_logger.logger.info(`[Cloudfront] 👍 S3 website already associated with distribution...`);
return distributionConfig;
}
_logger.logger.info(`[Cloudfront] ✏️ Generating new S3 website association config for "${domainName}"...`);
return _objectSpread(_objectSpread({}, distributionConfig), {}, {
Origins: _objectSpread(_objectSpread({}, distributionConfig.Origins), {}, {
Quantity: 1,
Items: [{
Id: (0, _awsServices.getOriginId)(domainName),
DomainName: (0, _awsServices.getS3DomainName)(domainName),
CustomOriginConfig: {
HTTPPort: 80,
HTTPSPort: 443,
OriginProtocolPolicy: _clientCloudfront.OriginProtocolPolicy.https_only,
OriginSslProtocols: {
Quantity: 1,
Items: ['TLSv1']
},
OriginReadTimeout: 30,
OriginKeepaliveTimeout: 5
},
CustomHeaders: {
Quantity: 0,
Items: []
},
OriginPath: ''
}]
}),
DefaultCacheBehavior: _objectSpread(_objectSpread({}, distributionConfig.DefaultCacheBehavior), {}, {
TargetOriginId: (0, _awsServices.getOriginId)(domainName),
ViewerProtocolPolicy: distributionConfig.DefaultCacheBehavior?.ViewerProtocolPolicy || 'redirect-to-https'
})
});
};
const add403RedirectionToRoot = distributionConfig => {
const existingErrorResponse = distributionConfig.CustomErrorResponses?.Items?.some(item => item.ErrorCode === 403);
if (existingErrorResponse) {
_logger.logger.info(`[Cloudfront] 👍 a custom 403 error response already exists...`);
return distributionConfig;
}
_logger.logger.info(`[Cloudfront] ✏️ Adding custom 403 error response to distribution...`);
return _objectSpread(_objectSpread({}, distributionConfig), {}, {
CustomErrorResponses: {
Quantity: (distributionConfig.CustomErrorResponses?.Quantity || 0) + 1,
Items: [...(distributionConfig.CustomErrorResponses?.Items || []), {
ErrorCode: 403,
ResponsePagePath: '/index.html',
ResponseCode: '200',
ErrorCachingMinTTL: 10
}]
}
});
};
exports.add403RedirectionToRoot = add403RedirectionToRoot;