Skip to content

Commit

Permalink
✨ allow to specify cache behavior
Browse files Browse the repository at this point in the history
  • Loading branch information
nicgirault committed Nov 15, 2019
1 parent 150f7c0 commit 17e88ea
Show file tree
Hide file tree
Showing 7 changed files with 61 additions and 28 deletions.
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
12 changes: 12 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -46,6 +56,8 @@ yargs
argv.domainName,
argv.directory,
argv.wait,
argv.cacheInvalidation,
argv.cacheBustedPrefix,
argv.credentials
);
logger.info("✅ done!");
Expand Down
28 changes: 24 additions & 4 deletions src/cloudfront.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down
5 changes: 3 additions & 2 deletions src/cloudfront.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ const getOriginId = (domainName: string) =>

export const invalidateCloudfrontCache = async (
distributionId: string,
paths: string,
wait: boolean = false
) => {
logger.info("[CloudFront] ✏️ Creating invalidation...");
Expand All @@ -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())
}
}
})
Expand Down
13 changes: 5 additions & 8 deletions src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("/");

Expand All @@ -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);
Expand Down Expand Up @@ -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);
};
4 changes: 2 additions & 2 deletions src/s3.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);

Expand Down
18 changes: 9 additions & 9 deletions src/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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}"...`);
Expand All @@ -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"
Expand All @@ -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.
Expand All @@ -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";
Expand Down

0 comments on commit 17e88ea

Please sign in to comment.