From 17e88eaba17d707bb3cba0e4f0a36297d9cd2129 Mon Sep 17 00:00:00 2001 From: nicgirault Date: Fri, 15 Nov 2019 17:14:22 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20allow=20to=20specify=20cache=20beha?= =?UTF-8?q?vior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 ++++++--- src/cli.ts | 12 ++++++++++++ src/cloudfront.spec.ts | 28 ++++++++++++++++++++++++---- src/cloudfront.ts | 5 +++-- src/deploy.ts | 13 +++++-------- src/s3.spec.ts | 4 ++-- src/s3.ts | 18 +++++++++--------- 7 files changed, 61 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 5b7afa2..747dce0 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,6 @@ Configuring the deployment of a single page app is harder than it should be. Mos - Create AWS Bucket & CloudFront distribution & Route 53 record & ACM certificate and configure it - Serve gzipped file -- [Smart](https://facebook.github.io/create-react-app/docs/production-build#static-file-caching) HTTP cache (cache busted files should be in the `static` subfolder of the build folder). - Invalidate CloudFront after deployment - Basic Auth (recommended to avoid search engine indexation) - idempotent script @@ -35,7 +34,9 @@ Configuring the deployment of a single page app is harder than it should be. Mos npx create-react-app hello-world && cd hello-world yarn add aws-spa yarn build -npx aws-spa deploy hello.example.com + +# read about [create-react-app static file caching](https://facebook.github.io/create-react-app/docs/production-build#static-file-cachin) +npx aws-spa deploy hello.example.com --cacheInvalidation "index.html" --cacheBustedPrefix "static/" ``` ## API @@ -60,7 +61,9 @@ aws-spa deploy app.example.com/$(git branch | grep * | cut -d ' ' -f2) - `--wait`: Wait for CloudFront distribution to be deployed & cache invalidation to be completed. If you choose not to wait (default), you won't see site changes as soon as the command ends. - `--directory`: The directory where the static files have been generated. It must contain an index.html. Default is `build`. -- `--credentials` This option enables basic auth for the full s3 bucket (even if the domainName specifies a path). Credentials must be of the form "username:password". Basic auth is the recommened way to avoid search engine indexation of non-production apps (such as staging). +- `--credentials`: This option enables basic auth for the full s3 bucket (even if the domainName specifies a path). Credentials must be of the form "username:password". Basic auth is the recommened way to avoid search engine indexation of non-production apps (such as staging). +- `--cacheInvalidation`: cache invalidation to be done in CloudFront. Default is `*`: all files are invalidated. For a `create-react-app` app you only need to invalidate `index.html` +- `--cacheBustedPrefix`: a folder where files are suffixed with a hash (cash busting). Their `cache-control` value is set to `max-age=31536000`. For a `create-react-app` app you can specify `static/`. ## Migrate an existing SPA on aws-spa diff --git a/src/cli.ts b/src/cli.ts index ed2268a..ee71ac4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -31,6 +31,16 @@ yargs describe: "The directory where the static files have been generated. It must contain an index.html file" }) + .option("cacheInvalidation", { + type: "string", + default: "*", + describe: + "The paths to invalidate on CloudFront. Default is all (*). You can specify several paths comma separated." + }) + .option("cacheBustedPrefix", { + type: "string", + describe: "A folder where files use cache busting strategy." + }) .option("credentials", { type: "string", describe: @@ -46,6 +56,8 @@ yargs argv.domainName, argv.directory, argv.wait, + argv.cacheInvalidation, + argv.cacheBustedPrefix, argv.credentials ); logger.info("✅ done!"); diff --git a/src/cloudfront.spec.ts b/src/cloudfront.spec.ts index 71d4666..6674642 100644 --- a/src/cloudfront.spec.ts +++ b/src/cloudfront.spec.ts @@ -112,22 +112,42 @@ describe("cloudfront", () => { waitForMock.mockReset(); }); - it("should invalidate index.html", async () => { + it("should invalidate the specified path", async () => { createInvalidationMock.mockReturnValue(awsResolve({ Invalidation: {} })); - await invalidateCloudfrontCache("some-distribution-id"); + await invalidateCloudfrontCache("some-distribution-id", "index.html"); expect(createInvalidationMock).toHaveBeenCalledTimes(1); const invalidationParams: any = createInvalidationMock.mock.calls[0][0]; expect(invalidationParams.DistributionId).toEqual("some-distribution-id"); expect(invalidationParams.InvalidationBatch.Paths.Items[0]).toEqual( - "/index.html" + "index.html" ); }); + it("should invalidate the specified paths", async () => { + createInvalidationMock.mockReturnValue(awsResolve({ Invalidation: {} })); + await invalidateCloudfrontCache( + "some-distribution-id", + "index.html, static/*" + ); + + expect(createInvalidationMock).toHaveBeenCalledTimes(1); + const invalidationParams: any = createInvalidationMock.mock.calls[0][0]; + expect(invalidationParams.DistributionId).toEqual("some-distribution-id"); + expect(invalidationParams.InvalidationBatch.Paths.Items).toEqual([ + "index.html", + "static/*" + ]); + }); + it("should wait for invalidate if wait flag is true", async () => { createInvalidationMock.mockReturnValue(awsResolve({ Invalidation: {} })); waitForMock.mockReturnValue(awsResolve()); - await invalidateCloudfrontCache("some-distribution-id", true); + await invalidateCloudfrontCache( + "some-distribution-id", + "index.html", + true + ); expect(waitForMock).toHaveBeenCalledTimes(1); expect(waitForMock.mock.calls[0][0]).toEqual("invalidationCompleted"); }); diff --git a/src/cloudfront.ts b/src/cloudfront.ts index 8dacfd8..4adbde4 100644 --- a/src/cloudfront.ts +++ b/src/cloudfront.ts @@ -243,6 +243,7 @@ const getOriginId = (domainName: string) => export const invalidateCloudfrontCache = async ( distributionId: string, + paths: string, wait: boolean = false ) => { logger.info("[CloudFront] ✏️ Creating invalidation..."); @@ -252,8 +253,8 @@ export const invalidateCloudfrontCache = async ( InvalidationBatch: { CallerReference: Date.now().toString(), Paths: { - Quantity: 1, - Items: ["/index.html"] + Quantity: paths.split(",").length, + Items: paths.split(",").map(path => path.trim()) } } }) diff --git a/src/deploy.ts b/src/deploy.ts index c80ef68..501bfa3 100644 --- a/src/deploy.ts +++ b/src/deploy.ts @@ -29,7 +29,9 @@ export const deploy = async ( url: string, folder: string, wait: boolean, - credentials?: string + cacheInvalidations: string, + cacheBustedPrefix: string | undefined, + credentials: string | undefined ) => { const [domainName, s3Folder] = url.split("/"); @@ -44,11 +46,6 @@ export const deploy = async ( if (!existsSync(`${folder}/index.html`)) { throw new Error(`"index.html" not found in "${folder}" folder`); } - if (!existsSync(`${folder}/static`)) { - logger.warn( - `folder "${folder}/static" does not exists. Only files in this folder are assumed to have a hash as explained in https://facebook.github.io/create-react-app/docs/production-build#static-file-caching and will be aggressively cached` - ); - } if (await doesS3BucketExists(domainName)) { await confirmBucketManagement(domainName); @@ -99,6 +96,6 @@ export const deploy = async ( await updateRecord(hostedZone.Id, domainName, distribution.DomainName); } - await syncToS3(folder, domainName, s3Folder); - await invalidateCloudfrontCache(distribution.Id, wait); + await syncToS3(folder, domainName, cacheBustedPrefix, s3Folder); + await invalidateCloudfrontCache(distribution.Id, cacheInvalidations, wait); }; diff --git a/src/s3.spec.ts b/src/s3.spec.ts index f35ba10..81c3462 100644 --- a/src/s3.spec.ts +++ b/src/s3.spec.ts @@ -303,7 +303,7 @@ describe("s3", () => { }); it("should call s3.putObject for each file returned by readRecursively", async () => { - await syncToS3("some-folder", "some-bucket"); + await syncToS3("some-folder", "some-bucket", "static/"); expect(readRecursivelyMock).toHaveBeenCalledWith("some-folder"); expect(putObjectSpy).toHaveBeenCalledTimes(someFiles.length); for (const call of putObjectSpy.mock.calls as any) { @@ -313,7 +313,7 @@ describe("s3", () => { }); it("should set the right cache-control", async () => { - await syncToS3("some-folder", "some-bucket"); + await syncToS3("some-folder", "some-bucket", "static/"); expect(readRecursivelyMock).toHaveBeenCalledWith("some-folder"); expect(putObjectSpy).toHaveBeenCalledTimes(someFiles.length); diff --git a/src/s3.ts b/src/s3.ts index aad44fb..56ef1e7 100644 --- a/src/s3.ts +++ b/src/s3.ts @@ -44,9 +44,7 @@ export const createBucket = async (bucketName: string) => { export const confirmBucketManagement = async (bucketName: string) => { logger.info( - `[S3] 🔍 Checking that tag "${identifyingTag.Key}:${ - identifyingTag.Value - }" exists on bucket "${bucketName}"...` + `[S3] 🔍 Checking that tag "${identifyingTag.Key}:${identifyingTag.Value}" exists on bucket "${bucketName}"...` ); try { const { TagSet } = await s3 @@ -86,9 +84,7 @@ export const confirmBucketManagement = async (bucketName: string) => { export const tagBucket = async (bucketName: string) => { logger.info( - `[S3] ✏️ Tagging "${bucketName}" bucket with "${identifyingTag.Key}:${ - identifyingTag.Value - }"...` + `[S3] ✏️ Tagging "${bucketName}" bucket with "${identifyingTag.Key}:${identifyingTag.Value}"...` ); await s3 .putBucketTagging({ @@ -149,6 +145,7 @@ export const identifyingTag: Tag = { export const syncToS3 = function( folder: string, bucketName: string, + cacheBustedPrefix: string | undefined, subfolder?: string ) { logger.info(`[S3] ✏️ Uploading "${folder}" folder on "${bucketName}"...`); @@ -165,7 +162,7 @@ export const syncToS3 = function( Bucket: bucketName, Key: `${prefix}${key}`, Body: createReadStream(file), - CacheControl: getCacheControl(key), + CacheControl: getCacheControl(key, cacheBustedPrefix), ContentType: lookup(filenameParts[filenameParts.length - 1]) || "application/octet-stream" @@ -175,7 +172,10 @@ export const syncToS3 = function( ); }; -const getCacheControl = (filename: string) => { +const getCacheControl = ( + filename: string, + cacheBustedPrefix: string | undefined +) => { if (filename === "index.html") { // This will allow CloudFront to store the file on the edge location, // but it will force it to revalidate it with the origin with each request. @@ -184,7 +184,7 @@ const getCacheControl = (filename: string) => { return "public, must-revalidate, proxy-revalidate, max-age=0"; } - if (filename.startsWith("static/")) { + if (cacheBustedPrefix && filename.startsWith(cacheBustedPrefix)) { // js & css files should have a hash so if index.html change: the js & css // file will change. It allows to have an aggressive cache for js & css files. return "max-age=31536000";