UNPKG

aws-spa

Version:

A no-brainer script to deploy a single page app on AWS

582 lines (482 loc) 19.5 kB
"use strict"; 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;