Created
April 26, 2024 19:07
-
-
Save NamesMT/5b78ab557cad8fab0d6ccef4c887b194 to your computer and use it in GitHub Desktop.
SST Ion prototype for Static frontend + backend
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
import fs from "fs"; | |
import path from "path"; | |
import crypto from "crypto"; | |
import * as aws from "@pulumi/aws"; | |
import { | |
ComponentResourceOptions, | |
all, | |
interpolate, | |
output, | |
} from "@pulumi/pulumi"; | |
import { Cdn, CdnArgs } from "./cdn.js"; | |
import { Bucket, BucketArgs } from "./bucket.js"; | |
import { Component, Prettify, Transform, transform } from "../component.js"; | |
import { Link } from "../link.js"; | |
import { Input } from "../input.js"; | |
import { globSync } from "glob"; | |
import { BucketFile, BucketFiles } from "./providers/bucket-files.js"; | |
import { DistributionInvalidation } from "./providers/distribution-invalidation.js"; | |
import { | |
BaseStaticSiteArgs, | |
buildApp, | |
cleanup, | |
prepare, | |
} from "../base/base-static-site.js"; | |
export interface StaticSiteWithBackendArgs extends BaseStaticSiteArgs { | |
/** | |
* Path to the directory where your static site is located. By default this assumes your static site is in the root of your SST app. | |
* | |
* This directory will be uploaded to S3. The path is relative to your `sst.config.ts`. | |
* | |
* :::note | |
* If the `build` options are specified, `build.output` will be uploaded to S3 instead. | |
* ::: | |
* | |
* If you are using a static site generator, like Vite, you'll need to configure the `build` options. When these are set, the `build.output` directory will be uploaded to S3 instead. | |
* | |
* @default `"."` | |
* | |
* @example | |
* | |
* Change where your static site is located. | |
* | |
* ```js | |
* { | |
* path: "packages/web" | |
* } | |
* ``` | |
*/ | |
path?: BaseStaticSiteArgs["path"]; | |
/** | |
* Configure if your static site needs to be built. This is useful if you are using a static site generator. | |
* | |
* The `build.output` directory will be uploaded to S3 instead. | |
* @example | |
* For a Vite project using npm this might look like this. | |
* | |
* ```js | |
* { | |
* build: { | |
* command: "npm run build", | |
* output: "dist" | |
* } | |
* } | |
* ``` | |
*/ | |
build?: BaseStaticSiteArgs["build"]; | |
/** | |
* Configure how the static site's assets are uploaded to S3. | |
* | |
* By default, this is set to the following. Read more about these options below. | |
* ```js | |
* { | |
* assets: { | |
* textEncoding: "utf-8", | |
* fileOptions: [ | |
* { | |
* files: ["**\/*.css", "**\/*.js"], | |
* cacheControl: "max-age=31536000,public,immutable" | |
* }, | |
* { | |
* files: "**\/*.html", | |
* cacheControl: "max-age=0,no-cache,no-store,must-revalidate" | |
* } | |
* ] | |
* } | |
* } | |
* ``` | |
* @default `Object` | |
*/ | |
assets?: BaseStaticSiteArgs["assets"]; | |
/** | |
* Set a custom domain for your static site. Supports domains hosted either on | |
* [Route 53](https://aws.amazon.com/route53/) or outside AWS. | |
* | |
* :::tip | |
* You can also migrate an externally hosted domain to Amazon Route 53 by | |
* [following this guide](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/MigratingDNS.html). | |
* ::: | |
* | |
* @example | |
* | |
* ```js | |
* { | |
* domain: "domain.com" | |
* } | |
* ``` | |
* | |
* Specify a `www.` version of the custom domain. | |
* | |
* ```js | |
* { | |
* domain: { | |
* name: "domain.com", | |
* redirects: ["www.domain.com"] | |
* } | |
* } | |
* ``` | |
*/ | |
domain?: CdnArgs["domain"]; | |
/** | |
* Configure how the CloudFront cache invalidations are handled. This is run after your static site has been deployed. | |
* :::tip | |
* You get 1000 free invalidations per month. After that you pay $0.005 per invalidation path. [Read more here](https://aws.amazon.com/cloudfront/pricing/). | |
* ::: | |
* @default `{paths: "all", wait: false}` | |
* @example | |
* Turn off invalidations. | |
* ```js | |
* { | |
* invalidation: false | |
* } | |
* ``` | |
* Wait for all paths to be invalidated. | |
* ```js | |
* { | |
* invalidation: { | |
* paths: "all", | |
* wait: true | |
* } | |
* } | |
* ``` | |
*/ | |
invalidation?: Input< | |
| false | |
| { | |
/** | |
* Configure if `sst deploy` should wait for the CloudFront cache invalidation to finish. | |
* | |
* :::tip | |
* For non-prod environments it might make sense to pass in `false`. | |
* ::: | |
* | |
* Waiting for the CloudFront cache invalidation process to finish ensures that the new content will be served once the deploy finishes. However, this process can sometimes take more than 5 mins. | |
* @default `false` | |
* @example | |
* ```js | |
* { | |
* invalidation: { | |
* wait: true | |
* } | |
* } | |
* ``` | |
*/ | |
wait?: Input<boolean>; | |
/** | |
* The paths to invalidate. | |
* | |
* You can either pass in an array of glob patterns to invalidate specific files. Or you can use the built-in option `all` to invalidation all files when any file changes. | |
* | |
* :::note | |
* Invalidating `all` counts as one invalidation, while each glob pattern counts as a single invalidation path. | |
* ::: | |
* @default `"all"` | |
* @example | |
* Invalidate the `index.html` and all files under the `products/` route. | |
* ```js | |
* { | |
* invalidation: { | |
* paths: ["/index.html", "/products/*"] | |
* } | |
* } | |
* ``` | |
*/ | |
paths?: Input<"all" | string[]>; | |
} | |
>; | |
backend?: Input<string> | |
/** | |
* [Transform](/docs/components#transform) how this component creates its underlying | |
* resources. | |
*/ | |
transform?: { | |
/** | |
* Transform the Bucket resource used for uploading the assets. | |
*/ | |
assets?: Transform<BucketArgs>; | |
/** | |
* Transform the CloudFront CDN resource. | |
*/ | |
cdn?: Transform<CdnArgs>; | |
}; | |
} | |
/** | |
* The `StaticSite` component lets you deploy a static website to AWS. It uses [Amazon S3](https://aws.amazon.com/s3/) to store your files and [Amazon CloudFront](https://aws.amazon.com/cloudfront/) to serve them. | |
* | |
* It can also `build` your site by running your static site generator, like [Vite](https://vitejs.dev) and uploading the build output to S3. | |
* | |
* @example | |
* | |
* #### Minimal example | |
* | |
* Simply uploads the current directory as a static site. | |
* | |
* ```js | |
* new sst.aws.StaticSite("MyWeb"); | |
* ``` | |
* | |
* #### Change the path | |
* | |
* Change the `path` that should be uploaded. | |
* | |
* ```js | |
* new sst.aws.StaticSite("MyWeb", { | |
* path: "path/to/site" | |
* }); | |
* ``` | |
* | |
* #### Running locally | |
* | |
* In `sst dev`, we don't deploy your site to AWS because we assume you are running it locally. | |
* | |
* :::note | |
* Your static site will not be deployed when run locally with `sst dev`. | |
* ::: | |
* | |
* For example, for a Vite site, you can run it locally with. | |
* | |
* ```bash | |
* sst dev vite dev | |
* ``` | |
* | |
* This will start the Vite dev server and pass in any environment variables that you've set in your config. But it will not deploy your site to AWS. | |
* | |
* #### Deploy a Vite SPA | |
* | |
* Use [Vite](https://vitejs.dev) to deploy a React/Vue/Svelte/etc. SPA by specifying the `build` config. | |
* | |
* ```js | |
* new sst.aws.StaticSite("MyWeb", { | |
* build: { | |
* command: "npm run build", | |
* output: "dist" | |
* } | |
* }); | |
* ``` | |
* | |
* #### Deploy a Jekyll site | |
* | |
* Use [Jekyll](https://jekyllrb.com) to deploy a static site. | |
* | |
* ```js | |
* new sst.aws.StaticSite("MyWeb", { | |
* errorPage: "404.html", | |
* build: { | |
* command: "bundle exec jekyll build", | |
* output: "_site" | |
* } | |
* }); | |
* ``` | |
* | |
* #### Deploy a Gatsby site | |
* | |
* Use [Gatsby](https://www.gatsbyjs.com) to deploy a static site. | |
* | |
* ```js | |
* new sst.aws.StaticSite("MyWeb", { | |
* errorPage: "404.html", | |
* build: { | |
* command: "npm run build", | |
* output: "public" | |
* } | |
* }); | |
* ``` | |
* | |
* #### Deploy an Angular SPA | |
* | |
* Use [Angular](https://angular.dev) to deploy a SPA. | |
* | |
* ```js | |
* new sst.aws.StaticSite("MyWeb", { | |
* build: { | |
* command: "ng build --output-path dist", | |
* output: "dist" | |
* } | |
* }); | |
* ``` | |
* | |
* #### Add a custom domain | |
* | |
* Set a custom domain for your site. | |
* | |
* ```js {2} | |
* new sst.aws.StaticSite("MyWeb", { | |
* domain: "my-app.com" | |
* }); | |
* ``` | |
* | |
* #### Redirect www to apex domain | |
* | |
* Redirect `www.my-app.com` to `my-app.com`. | |
* | |
* ```js {4} | |
* new sst.aws.StaticSite("MyWeb", { | |
* domain: { | |
* name: "my-app.com", | |
* redirects: ["www.my-app.com"] | |
* } | |
* }); | |
* ``` | |
* | |
* #### Set environment variables | |
* | |
* Set `environment` variables for the build process of your static site. These will be used locally and on deploy. | |
* | |
* :::tip | |
* For Vite, the types for the environment variables are also generated. This can be configured through the `vite` prop. | |
* ::: | |
* | |
* For some static site generators like Vite, [environment variables](https://vitejs.dev/guide/env-and-mode) prefixed with `VITE_` can be accessed in the browser. | |
* | |
* ```ts {5-7} | |
* const bucket = new sst.aws.Bucket("MyBucket"); | |
* | |
* new sst.aws.StaticSite("MyWeb", { | |
* environment: { | |
* BUCKET_NAME: bucket.name, | |
* // Accessible in the browser | |
* VITE_STRIPE_PUBLISHABLE_KEY: "pk_test_123" | |
* }, | |
* build: { | |
* command: "npm run build", | |
* output: "dist" | |
* } | |
* }); | |
* ``` | |
*/ | |
export class StaticSiteWithBackend extends Component implements Link.Linkable { | |
private cdn: Cdn; | |
private assets: Bucket; | |
constructor( | |
name: string, | |
args: StaticSiteWithBackendArgs = {}, | |
opts: ComponentResourceOptions = {}, | |
) { | |
super(__pulumiType, name, args, opts); | |
const parent = this; | |
const { sitePath, environment, indexPage } = prepare(args); | |
const outputPath = buildApp(name, args.build, sitePath, environment); | |
const access = createCloudFrontOriginAccessIdentity(); | |
const bucket = createS3Bucket(); | |
const bucketFile = uploadAssets(); | |
const distribution = createDistribution(); | |
createDistributionInvalidation(); | |
this.assets = bucket; | |
this.cdn = distribution; | |
this.registerOutputs({ | |
...cleanup(this.url, sitePath, environment), | |
_metadata: { | |
path: sitePath, | |
environment, | |
url: this.url, | |
}, | |
}); | |
function createCloudFrontOriginAccessIdentity() { | |
return new aws.cloudfront.OriginAccessIdentity( | |
`${name}OriginAccessIdentity`, | |
{}, | |
{ parent }, | |
); | |
} | |
function createS3Bucket() { | |
return new Bucket( | |
`${name}Assets`, | |
transform(args.transform?.assets, { | |
transform: { | |
policy: (policyArgs) => { | |
const newPolicy = aws.iam.getPolicyDocumentOutput({ | |
statements: [ | |
{ | |
principals: [ | |
{ | |
type: "AWS", | |
identifiers: [access.iamArn], | |
}, | |
], | |
actions: ["s3:GetObject"], | |
resources: [interpolate`${bucket.arn}/*`], | |
}, | |
], | |
}).json; | |
policyArgs.policy = output([policyArgs.policy, newPolicy]).apply( | |
([policy, newPolicy]) => { | |
const policyJson = JSON.parse(policy as string); | |
const newPolicyJson = JSON.parse(newPolicy as string); | |
policyJson.Statement.push(...newPolicyJson.Statement); | |
return JSON.stringify(policyJson); | |
}, | |
); | |
}, | |
}, | |
}), | |
{ parent, retainOnDelete: false }, | |
); | |
} | |
function uploadAssets() { | |
return all([outputPath, args.assets]).apply( | |
async ([outputPath, assets]) => { | |
const bucketFiles: BucketFile[] = []; | |
// Build fileOptions | |
const fileOptions = assets?.fileOptions ?? [ | |
{ | |
files: "**", | |
cacheControl: "max-age=0,no-cache,no-store,must-revalidate", | |
}, | |
{ | |
files: ["**/*.js", "**/*.css"], | |
cacheControl: "max-age=31536000,public,immutable", | |
}, | |
]; | |
// Upload files based on fileOptions | |
const filesProcessed: string[] = []; | |
for (const fileOption of fileOptions.reverse()) { | |
const files = globSync(fileOption.files, { | |
cwd: path.resolve(outputPath), | |
nodir: true, | |
dot: true, | |
ignore: [ | |
".sst/**", | |
...(typeof fileOption.ignore === "string" | |
? [fileOption.ignore] | |
: fileOption.ignore ?? []), | |
], | |
}).filter((file) => !filesProcessed.includes(file)); | |
bucketFiles.push( | |
...(await Promise.all( | |
files.map(async (file) => { | |
const source = path.resolve(outputPath, file); | |
const content = await fs.promises.readFile(source); | |
const hash = crypto | |
.createHash("sha256") | |
.update(content) | |
.digest("hex"); | |
return { | |
source, | |
key: file, | |
hash, | |
cacheControl: fileOption.cacheControl, | |
contentType: getContentType(file, "UTF-8"), | |
}; | |
}), | |
)), | |
); | |
filesProcessed.push(...files); | |
} | |
return new BucketFiles( | |
`${name}AssetFiles`, | |
{ | |
bucketName: bucket.name, | |
files: bucketFiles, | |
}, | |
{ parent, ignoreChanges: $dev ? ["*"] : undefined }, | |
); | |
}, | |
); | |
} | |
function getContentType(filename: string, textEncoding: string) { | |
const ext = filename.endsWith(".well-known/site-association-json") | |
? ".json" | |
: path.extname(filename); | |
const extensions = { | |
[".txt"]: { mime: "text/plain", isText: true }, | |
[".htm"]: { mime: "text/html", isText: true }, | |
[".html"]: { mime: "text/html", isText: true }, | |
[".xhtml"]: { mime: "application/xhtml+xml", isText: true }, | |
[".css"]: { mime: "text/css", isText: true }, | |
[".js"]: { mime: "text/javascript", isText: true }, | |
[".mjs"]: { mime: "text/javascript", isText: true }, | |
[".apng"]: { mime: "image/apng", isText: false }, | |
[".avif"]: { mime: "image/avif", isText: false }, | |
[".gif"]: { mime: "image/gif", isText: false }, | |
[".jpeg"]: { mime: "image/jpeg", isText: false }, | |
[".jpg"]: { mime: "image/jpeg", isText: false }, | |
[".png"]: { mime: "image/png", isText: false }, | |
[".svg"]: { mime: "image/svg+xml", isText: true }, | |
[".bmp"]: { mime: "image/bmp", isText: false }, | |
[".tiff"]: { mime: "image/tiff", isText: false }, | |
[".webp"]: { mime: "image/webp", isText: false }, | |
[".ico"]: { mime: "image/vnd.microsoft.icon", isText: false }, | |
[".eot"]: { mime: "application/vnd.ms-fontobject", isText: false }, | |
[".ttf"]: { mime: "font/ttf", isText: false }, | |
[".otf"]: { mime: "font/otf", isText: false }, | |
[".woff"]: { mime: "font/woff", isText: false }, | |
[".woff2"]: { mime: "font/woff2", isText: false }, | |
[".json"]: { mime: "application/json", isText: true }, | |
[".jsonld"]: { mime: "application/ld+json", isText: true }, | |
[".xml"]: { mime: "application/xml", isText: true }, | |
[".pdf"]: { mime: "application/pdf", isText: false }, | |
[".zip"]: { mime: "application/zip", isText: false }, | |
[".wasm"]: { mime: "application/wasm", isText: false }, | |
}; | |
const extensionData = extensions[ext as keyof typeof extensions]; | |
const mime = extensionData?.mime ?? "application/octet-stream"; | |
const charset = | |
extensionData?.isText && textEncoding !== "none" | |
? `;charset=${textEncoding}` | |
: ""; | |
return `${mime}${charset}`; | |
} | |
function createDistribution() { | |
return new Cdn( | |
`${name}Cdn`, | |
transform(args.transform?.cdn, { | |
comment: `${name} site`, | |
origins: output(args.backend).apply((backend) => { | |
const origins: CdnArgs['origins'] = [{ | |
originId: "s3", | |
domainName: bucket.nodes.bucket.bucketRegionalDomainName, | |
originPath: "", | |
s3OriginConfig: { | |
originAccessIdentity: access.cloudfrontAccessIdentityPath, | |
}, | |
}] | |
if (backend) | |
origins.push({ | |
originId: "backend", | |
domainName: new URL(backend).host, | |
...{ // defaultConfig from ./router | |
customOriginConfig: { | |
httpPort: 80, | |
httpsPort: 443, | |
originProtocolPolicy: "https-only", | |
originReadTimeout: 20, | |
originSslProtocols: ["TLSv1.2"], | |
}, | |
} | |
}) | |
return origins; | |
}), | |
defaultRootObject: indexPage, | |
customErrorResponses: args.errorPage | |
? [ | |
{ | |
errorCode: 403, | |
responsePagePath: interpolate`/${args.errorPage}`, | |
}, | |
{ | |
errorCode: 404, | |
responsePagePath: interpolate`/${args.errorPage}`, | |
}, | |
] | |
: [ | |
{ | |
errorCode: 403, | |
responsePagePath: interpolate`/${indexPage}`, | |
responseCode: 200, | |
}, | |
{ | |
errorCode: 404, | |
responsePagePath: interpolate`/${indexPage}`, | |
responseCode: 200, | |
}, | |
], | |
defaultCacheBehavior: { | |
targetOriginId: "s3", | |
viewerProtocolPolicy: "redirect-to-https", | |
allowedMethods: ["GET", "HEAD", "OPTIONS"], | |
cachedMethods: ["GET", "HEAD"], | |
compress: true, | |
// CloudFront's managed CachingOptimized policy | |
cachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6", | |
}, | |
orderedCacheBehaviors: output(args.backend).apply((backend) => { | |
return (!backend ? [] : [{ | |
targetOriginId: "backend", | |
pathPattern: "/api/*", | |
...{ // defaultConfig from ./router, with cachePolicy set to CachingDisabled | |
viewerProtocolPolicy: "redirect-to-https", | |
allowedMethods: [ | |
"DELETE", | |
"GET", | |
"HEAD", | |
"OPTIONS", | |
"PATCH", | |
"POST", | |
"PUT", | |
], | |
cachedMethods: ["GET", "HEAD"], | |
defaultTtl: 0, | |
compress: true, | |
cachePolicyId: "4135ea2d-6df8-44a3-9df3-4b5a84be39ad", | |
// CloudFront's Managed-AllViewerExceptHostHeader policy | |
originRequestPolicyId: "b689b0a8-53d0-40ab-baf2-68738e2966ac", | |
} | |
}]) as aws.types.input.cloudfront.DistributionOrderedCacheBehavior[] | |
}), | |
domain: args.domain, | |
wait: !$dev, | |
}), | |
// create distribution after s3 upload finishes | |
{ dependsOn: bucketFile, parent }, | |
); | |
} | |
function createDistributionInvalidation() { | |
all([outputPath, args.invalidation]).apply( | |
([outputPath, invalidationRaw]) => { | |
// Normalize invalidation | |
if (invalidationRaw === false) return; | |
const invalidation = { | |
wait: false, | |
paths: "all" as const, | |
...invalidationRaw, | |
}; | |
// Build invalidation paths | |
const invalidationPaths = | |
invalidation.paths === "all" ? ["/*"] : invalidation.paths; | |
if (invalidationPaths.length === 0) return; | |
// Calculate a hash based on the contents of the S3 files. This will be | |
// used to determine if we need to invalidate our CloudFront cache. | |
// | |
// The below options are needed to support following symlinks when building zip files: | |
// - nodir: This will prevent symlinks themselves from being copied into the zip. | |
// - follow: This will follow symlinks and copy the files within. | |
const hash = crypto.createHash("md5"); | |
globSync("**", { | |
dot: true, | |
nodir: true, | |
follow: true, | |
cwd: path.resolve(outputPath), | |
}).forEach((filePath) => | |
hash.update(fs.readFileSync(path.resolve(outputPath, filePath))), | |
); | |
new DistributionInvalidation( | |
`${name}Invalidation`, | |
{ | |
distributionId: distribution.nodes.distribution.id, | |
paths: invalidationPaths, | |
version: hash.digest("hex"), | |
wait: invalidation.wait, | |
}, | |
{ | |
parent, | |
ignoreChanges: $dev ? ["*"] : undefined, | |
}, | |
); | |
}, | |
); | |
} | |
} | |
/** | |
* The URL of the website. | |
* | |
* If the `domain` is set, this is the URL with the custom domain. | |
* Otherwise, it's the autogenerated CloudFront URL. | |
*/ | |
public get url() { | |
return all([this.cdn.domainUrl, this.cdn.url]).apply( | |
([domainUrl, url]) => domainUrl ?? url, | |
); | |
} | |
/** | |
* The underlying [resources](/docs/components/#nodes) this component creates. | |
*/ | |
public get nodes() { | |
return { | |
/** | |
* The Amazon S3 Bucket that stores the assets. | |
*/ | |
assets: this.assets, | |
/** | |
* The Amazon CloudFront CDN that serves the site. | |
*/ | |
cdn: this.cdn, | |
}; | |
} | |
/** @internal */ | |
public getSSTLink() { | |
return { | |
properties: { | |
url: this.url, | |
}, | |
}; | |
} | |
} | |
const __pulumiType = "sst:aws:StaticSiteWithBackend"; | |
// @ts-expect-error | |
StaticSiteWithBackend.__pulumiType = __pulumiType; |
Hey! This looks fantastic, but I couldn't make it work, so I just used a transform function. Below the piece of code: leonacostaok/64ec684521e445fce5d85be3ee7029dc
Great & clean workaround!,
I initially created the gist to link it into an issue only so it's now pretty old and not working for the current is expected.
Gosh I wish the team would put more focus on and provide official component for such a simple stack.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hey! This looks fantastic, but I couldn't make it work, so I just used a transform function. Below the piece of code:
https://gist.github.com/leonacostaok/64ec684521e445fce5d85be3ee7029dc