Skip to content

Instantly share code, notes, and snippets.

@clshortfuse
Created September 11, 2019 15:29
Show Gist options
  • Save clshortfuse/697bbd213d530dc9cb99828950ae25c4 to your computer and use it in GitHub Desktop.
Save clshortfuse/697bbd213d530dc9cb99828950ae25c4 to your computer and use it in GitHub Desktop.
AWS S3/CloudFront Deployer Webpack Plugin
// 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