Created
September 11, 2019 15:29
-
-
Save clshortfuse/697bbd213d530dc9cb99828950ae25c4 to your computer and use it in GitHub Desktop.
AWS S3/CloudFront Deployer Webpack Plugin
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// const path = require('path'); | |
// const AWS = require('aws-sdk'); | |
// const mime = require('mime'); | |
// const crypto = require('crypto'); | |
/** | |
* @typedef {Object} AWSDeployer.CloudFrontInvalidation | |
* @prop {string} id | |
* @prop {string[]} paths | |
*/ | |
/** | |
* @typedef {Object} AWSDeployer.ConstructorOptions | |
* @prop {string} Bucket | |
* @prop {(AWSDeployer.CloudFrontInvalidation|AWSDeployer.CloudFrontInvalidation[]|'*')=} invalidate | |
*/ | |
class AWSDeployer { | |
/** @param {AWSDeployer.ConstructorOptions} options */ | |
constructor(options) { | |
this.options = options; | |
this.s3 = new AWS.S3({ apiVersion: '2006-03-01' }); | |
this.cloudfront = new AWS.CloudFront({ apiVersion: '2018-06-18' }); | |
} | |
/** | |
* @param {*} key | |
* @return {Promise<AWS.S3.HeadObjectOutput>} | |
*/ | |
getHeadObject(key) { | |
return new Promise((resolve, reject) => { | |
const headObjectParams = { | |
Bucket: this.options.Bucket, | |
Key: path.posix.format(path.parse(key)), | |
}; | |
this.s3.headObject(headObjectParams, (err, data) => { | |
if (err && err.statusCode !== 404 && err.statusCode !== 403) { | |
reject(err); | |
return; | |
} | |
resolve(data); | |
}); | |
}); | |
} | |
/** | |
* @param {string} filename | |
* @return {string} | |
*/ | |
static getKey(filename) { | |
return path.posix.format(path.parse(filename)); | |
} | |
/** | |
* @param {string} key | |
* @return {string} | |
*/ | |
static getContentType(key) { | |
if (key.indexOf('.') === -1) { | |
return 'text/html'; | |
} | |
return mime.getType(key); | |
} | |
/** @return {Promise<AWSDeployer.CloudFrontInvalidation[]>} */ | |
getCloudFrontDistributions() { | |
return new Promise((resolve, reject) => { | |
this.cloudfront.listDistributions((err, data) => { | |
if (err) { | |
reject(err); | |
return; | |
} | |
if (!data.DistributionList) { | |
resolve([]); | |
} | |
const distros = data.DistributionList.Items | |
.filter((distro) => distro.Enabled) | |
.map((distro) => ({ | |
id: distro.Id, | |
paths: distro.Origins.Items | |
.filter((origin) => origin.DomainName === `${this.options.Bucket}.s3.amazonaws.com`) | |
.map((origin) => origin.OriginPath || '/'), | |
})) | |
.filter((distro) => distro.paths); | |
resolve(distros); | |
}); | |
}); | |
} | |
/** | |
* @param {Compiler} compiler | |
* @return {void} | |
*/ | |
apply(compiler) { | |
compiler.hooks.afterEmit.tapPromise('AWSDeployer', (compilation) => { | |
/** @type {string[]} */ | |
const putKeys = []; | |
const filenames = Object.keys(compilation.assets); | |
return Promise.all(filenames.map((filename) => this.getHeadObject(filename) | |
.then((data) => new Promise((resolve) => { | |
if (!data) { | |
resolve(true); | |
return; | |
} | |
if (!data.ContentType || data.ContentType !== AWSDeployer.getContentType(filename)) { | |
resolve(true); | |
return; | |
} | |
if (!data.ETag) { | |
resolve(true); | |
return; | |
} | |
/** @type {Buffer} */ | |
const buffer = compilation.assets[filename].source(); | |
const digest = crypto.createHash('md5').update(buffer).digest('hex'); | |
if (data.ETag !== `"${digest}"`) { | |
resolve(true); | |
return; | |
} | |
resolve(false); | |
})) | |
.then((shouldUpload) => new Promise((resolve, reject) => { | |
if (!shouldUpload) { | |
resolve(); | |
return; | |
} | |
const key = AWSDeployer.getKey(filename); | |
/** @type {AWS.S3.PutObjectRequest} */ | |
const params = { | |
ACL: 'public-read', | |
Bucket: this.options.Bucket, | |
Body: compilation.assets[filename].source(), | |
ContentType: AWSDeployer.getContentType(filename), | |
Key: key, | |
}; | |
if (filename.toLowerCase().endsWith('.map')) { | |
params.ACL = 'private'; | |
} | |
if (filename !== key) { | |
console.log(`Uploading ${key} (${filename}) to ${this.options.Bucket}...`); | |
} else { | |
console.log(`Uploading ${key} to ${this.options.Bucket}...`); | |
} | |
this.s3.putObject(params, (err) => { | |
if (err) { | |
reject(err); | |
return; | |
} | |
putKeys.push(key); | |
console.log(`Uploaded ${filename} to ${this.options.Bucket}!`); | |
resolve(); | |
}); | |
})))) | |
.then(() => { | |
if (!putKeys.length || !this.options.invalidate) { | |
return Promise.resolve(/** @type {AWSDeployer.CloudFrontInvalidation[]} */ ([])); | |
} | |
if (this.options.invalidate === '*') { | |
return this.getCloudFrontDistributions(); | |
} | |
if (!Array.isArray(this.options.invalidate)) { | |
return Promise.resolve([this.options.invalidate]); | |
} | |
return Promise.resolve(this.options.invalidate); | |
}) | |
.then((distributions) => Promise.all(distributions | |
.map((distro) => new Promise((resolve, reject) => { | |
const batchItems = [] | |
.concat(...distro.paths.map((p) => putKeys.map((key) => path.posix.join(p, key)))); | |
/** @type {AWS.CloudFront.CreateInvalidationRequest} */ | |
const invalidationParams = { | |
DistributionId: distro.id, | |
InvalidationBatch: { | |
Paths: { | |
Quantity: batchItems.length, | |
Items: batchItems, | |
}, | |
CallerReference: `${Date.now()}-${Math.random().toString(36).substr(3)}`, | |
}, | |
}; | |
if (!batchItems.length) { | |
console.log(`No files to invalidate on ${distro.id}...`); | |
resolve(); | |
return; | |
} | |
console.log(`Invalidating ${batchItems.length} file${batchItems.length === 1 ? '' : 's'} on ${distro.id}...`); | |
this.cloudfront.createInvalidation(invalidationParams, (err, data) => { | |
if (err) { | |
reject(err); | |
return; | |
} | |
console.log(`Invalidation ${data.Invalidation.Id} created on ${distro.id}!`); | |
resolve(); | |
}); | |
})))); | |
}); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment