Last active
October 4, 2024 20:59
-
-
Save rmarscher/152ab4c99e1566f0cf58b3d10ca25ec7 to your computer and use it in GitHub Desktop.
Waku SST AWS Component
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 { | |
type ComponentResourceOptions, | |
type Output, | |
Unwrap, | |
all, | |
output, | |
} from "@pulumi/pulumi"; | |
import * as fs from "node:fs"; | |
import * as path from "node:path"; | |
import type { Bucket } from "../../.sst/platform/src/components/aws/bucket.js"; | |
import type { Cdn } from "../../.sst/platform/src/components/aws/cdn.js"; | |
import type { | |
FunctionArgs, | |
Function as SstAwsFunction, | |
} from "../../.sst/platform/src/components/aws/function.js"; | |
import { URL_UNAVAILABLE } from "../../.sst/platform/src/components/aws/linkable.js"; | |
import { buildApp } from "../../.sst/platform/src/components/base/base-ssr-site.js"; | |
import { Component } from "../../.sst/platform/src/components/component.js"; | |
import type { DevArgs } from "../../.sst/platform/src/components/dev.js"; | |
import { Link } from "../../.sst/platform/src/components/link.js"; | |
import { | |
type Plan, | |
type SsrSiteArgs, | |
createBucket, | |
createDevServer, | |
createServersAndDistribution, | |
prepare, | |
useCloudFrontFunctionHostHeaderInjection, | |
validatePlan, | |
} from "../../.sst/platform/src/components/aws/ssr-site.js"; | |
export interface WakuArgs extends SsrSiteArgs { | |
/** | |
* Configure how this component works in `sst dev`. | |
* | |
* :::note | |
* In `sst dev` your Remix app is run in dev mode; it's not deployed. | |
* ::: | |
* | |
* Instead of deploying your Remix app, this starts it in dev mode. It's run | |
* as a separate process in the `sst dev` multiplexer. Read more about | |
* [`sst dev`](/docs/reference/cli/#dev). | |
*/ | |
dev?: DevArgs["dev"]; | |
/** | |
* The number of instances of the [server function](#nodes-server) to keep warm. This is useful for cases where you are experiencing long cold starts. The default is to not keep any instances warm. | |
* | |
* This works by starting a serverless cron job to make _n_ concurrent requests to the server function every few minutes. Where _n_ is the number of instances to keep warm. | |
* | |
* @default `0` | |
*/ | |
warm?: SsrSiteArgs["warm"]; | |
/** | |
* Permissions and the resources that the [server function](#nodes-server) in your Waku site needs to access. These permissions are used to create the function's IAM role. | |
* | |
* :::tip | |
* If you `link` the function to a resource, the permissions to access it are | |
* automatically added. | |
* ::: | |
* | |
* @example | |
* Allow reading and writing to an S3 bucket called `my-bucket`. | |
* ```js | |
* { | |
* permissions: [ | |
* { | |
* actions: ["s3:GetObject", "s3:PutObject"], | |
* resources: ["arn:aws:s3:::my-bucket/*"] | |
* }, | |
* ] | |
* } | |
* ``` | |
* | |
* Perform all actions on an S3 bucket called `my-bucket`. | |
* | |
* ```js | |
* { | |
* permissions: [ | |
* { | |
* actions: ["s3:*"], | |
* resources: ["arn:aws:s3:::my-bucket/*"] | |
* }, | |
* ] | |
* } | |
* ``` | |
* | |
* Grant permissions to access all resources. | |
* | |
* ```js | |
* { | |
* permissions: [ | |
* { | |
* actions: ["*"], | |
* resources: ["*"] | |
* }, | |
* ] | |
* } | |
* ``` | |
*/ | |
permissions?: SsrSiteArgs["permissions"]; | |
/** | |
* Path to the directory where your Waku site is located. This path is relative to your `sst.config.ts`. | |
* | |
* By default it assumes your Waku site is in the root of your SST app. | |
* @default `"."` | |
* | |
* @example | |
* | |
* If your Waku site is in a package in your monorepo. | |
* | |
* ```js | |
* { | |
* path: "packages/web" | |
* } | |
* ``` | |
*/ | |
path?: SsrSiteArgs["path"]; | |
/** | |
* [Link resources](/docs/linking/) to your Waku site. This will: | |
* | |
* 1. Grant the permissions needed to access the resources. | |
* 2. Allow you to access it in your site using the [SDK](/docs/reference/sdk/). | |
* | |
* @example | |
* | |
* Takes a list of resources to link to the function. | |
* | |
* ```js | |
* { | |
* link: [bucket, stripeKey] | |
* } | |
* ``` | |
*/ | |
link?: SsrSiteArgs["link"]; | |
/** | |
* Set [environment variables](https://waku.gg/#environment-variables) in your Waku site. These are made available: | |
* | |
* 1. In `waku build`, they are loaded into `import.meta.env`. | |
* 2. Locally while running `sst dev waku dev`. | |
* | |
* :::tip | |
* You can also `link` resources to your Waku site and access them in a type-safe way with the [SDK](/docs/reference/sdk/). We recommend linking since it's more secure. | |
* ::: | |
* | |
* Recall that in Waku, you need to prefix your environment variables with `WAKU_PUBLIC_` to access them on the client-side. [Read more here](https://waku.gg/#environment-variables). | |
* | |
* @example | |
* ```js | |
* { | |
* environment: { | |
* API_URL: api.url, | |
* // Accessible on the client-side | |
* WAKU_PUBLIC_STRIPE_PUBLISHABLE_KEY: "pk_test_123" | |
* } | |
* } | |
* ``` | |
*/ | |
environment?: SsrSiteArgs["environment"]; | |
/** | |
* Set a custom domain for your Waku site. | |
* | |
* Automatically manages domains hosted on AWS Route 53, Cloudflare, and Vercel. For other | |
* providers, you'll need to pass in a `cert` that validates domain ownership and add the | |
* DNS records. | |
* | |
* :::tip | |
* Built-in support for AWS Route 53, Cloudflare, and Vercel. And manual setup for other | |
* providers. | |
* ::: | |
* | |
* @example | |
* | |
* By default this assumes the domain is hosted on Route 53. | |
* | |
* ```js | |
* { | |
* domain: "example.com" | |
* } | |
* ``` | |
* | |
* For domains hosted on Cloudflare. | |
* | |
* ```js | |
* { | |
* domain: { | |
* name: "example.com", | |
* dns: sst.cloudflare.dns() | |
* } | |
* } | |
* ``` | |
* | |
* Specify a `www.` version of the custom domain. | |
* | |
* ```js | |
* { | |
* domain: { | |
* name: "domain.com", | |
* redirects: ["www.domain.com"] | |
* } | |
* } | |
* ``` | |
*/ | |
domain?: SsrSiteArgs["domain"]; | |
/** | |
* The command used internally to build your Waku site. | |
* | |
* @default `"npm run build"` | |
* | |
* @example | |
* | |
* If you want to use a different build command. | |
* ```js | |
* { | |
* buildCommand: "yarn build" | |
* } | |
* ``` | |
*/ | |
buildCommand?: SsrSiteArgs["buildCommand"]; | |
/** | |
* Configure how the Waku site assets are uploaded to S3. | |
* | |
* By default, this is set to the following. Read more about these options below. | |
* ```js | |
* { | |
* assets: { | |
* textEncoding: "utf-8", | |
* versionedFilesCacheHeader: "public,max-age=31536000,immutable", | |
* nonVersionedFilesCacheHeader: "public,max-age=0,s-maxage=86400,stale-while-revalidate=8640" | |
* } | |
* } | |
* ``` | |
*/ | |
assets?: SsrSiteArgs["assets"]; | |
/** | |
* Configure the [server function](#nodes-server) in your Waku site to connect | |
* to private subnets in a virtual private cloud or VPC. This allows your site to | |
* access private resources. | |
* | |
* @example | |
* ```js | |
* { | |
* vpc: { | |
* securityGroups: ["sg-0399348378a4c256c"], | |
* subnets: ["subnet-0b6a2b73896dc8c4c", "subnet-021389ebee680c2f0"] | |
* } | |
* } | |
* ``` | |
*/ | |
vpc?: SsrSiteArgs["vpc"]; | |
/** | |
* Configure the Waku site to use an existing CloudFront cache policy. | |
* | |
* :::note | |
* CloudFront has a limit of 20 cache policies per account, though you can request a limit | |
* increase. | |
* ::: | |
* | |
* By default, a new cache policy is created for it. This allows you to reuse an existing | |
* policy instead of creating a new one. | |
* | |
* @default A new cache plolicy is created | |
* @example | |
* ```js | |
* { | |
* cachePolicy: "658327ea-f89d-4fab-a63d-7e88639e58f6" | |
* } | |
* ``` | |
*/ | |
cachePolicy?: SsrSiteArgs["cachePolicy"]; | |
server?: { | |
/** | |
* The amount of memory allocated to the server function. | |
* Takes values between 128 MB and 10240 MB in 1 MB increments. | |
* | |
* @default `"1024 MB"` | |
* @example | |
* ```js | |
* { | |
* server: { | |
* memory: "2048 MB" | |
* } | |
* } | |
* ``` | |
*/ | |
memory?: FunctionArgs["memory"]; | |
/** | |
* The [architecture](https://docs.aws.amazon.com/lambda/latest/dg/foundation-arch.html) | |
* of the server function. | |
* | |
* @default `"x86_64"` | |
* @example | |
* ```js | |
* { | |
* server: { | |
* architecture: "arm64" | |
* } | |
* } | |
* ``` | |
*/ | |
architecture?: FunctionArgs["architecture"]; | |
function?: Partial<Unwrap<FunctionArgs>>; | |
}; | |
} | |
/** | |
* The `Waku` component lets you deploy a [Waku](https://waku.gg/) site to AWS. | |
* | |
* @example | |
* | |
* #### Minimal example | |
* | |
* Deploy the Waku site that's in the project root. | |
* | |
* ```js title="sst.config.ts" | |
* new sst.aws.Waku("MyWeb"); | |
* ``` | |
* | |
* #### Change the path | |
* | |
* Deploys the Waku site in the `my-waku-app/` directory. | |
* | |
* ```js {2} title="sst.config.ts" | |
* new sst.aws.Waku("MyWeb", { | |
* path: "my-waku-app/" | |
* }); | |
* ``` | |
* | |
* #### Add a custom domain | |
* | |
* Set a custom domain for your Waku site. | |
* | |
* ```js {2} title="sst.config.ts" | |
* new sst.aws.Waku("MyWeb", { | |
* domain: "my-app.com" | |
* }); | |
* ``` | |
* | |
* #### Redirect www to apex domain | |
* | |
* Redirect `www.my-app.com` to `my-app.com`. | |
* | |
* ```js {4} title="sst.config.ts" | |
* new sst.aws.Waku("MyWeb", { | |
* domain: { | |
* name: "my-app.com", | |
* redirects: ["www.my-app.com"] | |
* } | |
* }); | |
* ``` | |
* | |
* #### Link resources | |
* | |
* [Link resources](/docs/linking/) to your Waku site. This will grant permissions | |
* to the resources and allow you to access it in your site. | |
* | |
* ```ts {4} title="sst.config.ts" | |
* const bucket = new sst.aws.Bucket("MyBucket"); | |
* | |
* new sst.aws.Waku("MyWeb", { | |
* link: [bucket] | |
* }); | |
* ``` | |
* | |
* You can use the [SDK](/docs/reference/sdk/) to access the linked resources | |
* in your Waku site. | |
* | |
* ```ts title="src/pages/index.waku" | |
* import { Resource } from "sst"; | |
* | |
* console.log(Resource.MyBucket.name); | |
* ``` | |
*/ | |
export class Waku extends Component implements Link.Linkable { | |
private cdn?: Output<Cdn>; | |
private assets?: Bucket; | |
private server?: Output<SstAwsFunction>; | |
private devUrl?: Output<string>; | |
constructor( | |
name: string, | |
args: WakuArgs = {}, | |
opts: ComponentResourceOptions = {}, | |
) { | |
super(__pulumiType, name, args, opts); | |
const { sitePath, partition } = prepare(args, opts); | |
if ($dev) { | |
const server = createDevServer(this, name, args); | |
this.devUrl = output(args.dev?.url ?? URL_UNAVAILABLE); | |
this.registerOutputs({ | |
_metadata: { | |
mode: "placeholder", | |
path: sitePath, | |
server: server.arn, | |
}, | |
_receiver: { | |
directory: sitePath, | |
links: output(args.link || []) | |
.apply(Link.build) | |
.apply((links) => links.map((link) => link.name)), | |
aws: { | |
role: server.nodes.role.arn, | |
}, | |
environment: args.environment, | |
}, | |
_dev: { | |
links: output(args.link || []) | |
.apply(Link.build) | |
.apply((links) => links.map((link) => link.name)), | |
aws: { | |
role: server.nodes.role.arn, | |
}, | |
environment: args.environment, | |
directory: output(args.dev?.directory).apply( | |
(dir) => dir || sitePath, | |
), | |
autostart: output(args.dev?.autostart).apply((val) => val ?? true), | |
command: output(args.dev?.command).apply( | |
(val) => val || "npm run dev", | |
), | |
}, | |
}); | |
return; | |
} | |
const { access, bucket } = createBucket(this, name, partition, args); | |
const outputPath = buildApp(this, name, args, sitePath, args.buildCommand); | |
rearrangeBuildOutput(outputPath); | |
const plan = buildPlan(); | |
// console.log(plan); | |
const { distribution, ssrFunctions, edgeFunctions } = | |
createServersAndDistribution( | |
this, | |
name, | |
args, | |
outputPath, | |
access, | |
bucket, | |
plan, | |
); | |
const serverFunction = ssrFunctions[0] ?? Object.values(edgeFunctions)[0]; | |
const distDir = "dist"; | |
const assetsDir = "assets"; | |
const functionDir = "function"; | |
const publicDir = "public"; | |
const privateDir = "private"; | |
const functionPublicDir = path.join(functionDir, publicDir); | |
this.assets = bucket; | |
this.cdn = distribution; | |
this.server = serverFunction; | |
this.registerOutputs({ | |
_hint: all([this.cdn.domainUrl, this.cdn.url]).apply( | |
([domainUrl, url]) => domainUrl ?? url, | |
), | |
_metadata: { | |
mode: "deployed", | |
path: sitePath, | |
url: distribution.apply((d) => d.domainUrl ?? d.url), | |
edge: plan.edge, | |
server: serverFunction.arn, | |
}, | |
}); | |
function rearrangeBuildOutput() { | |
return all([outputPath]).apply(([outputPath]) => { | |
const absoluteDistDir = path.join(outputPath, distDir); | |
const absoluteFunctionDir = path.join(absoluteDistDir, functionDir); | |
const absoluteFunctionPublicDir = path.join( | |
absoluteDistDir, | |
functionPublicDir, | |
); | |
const absolutePublicDir = path.join(absoluteDistDir, publicDir); | |
const absolutePrivateDir = path.join(outputPath, privateDir); | |
const absolutePrivateFunctionDir = path.join( | |
absoluteFunctionDir, | |
privateDir, | |
); | |
const absoluteTempDistDir = path.join(outputPath, "_dist"); | |
// Move the build from dist to dist/function | |
fs.renameSync(absoluteDistDir, absoluteTempDistDir); | |
fs.mkdirSync(absoluteDistDir); | |
fs.renameSync(absoluteTempDistDir, absoluteFunctionDir); | |
// Then move the dist/function/public folder to dist/public | |
fs.renameSync(absoluteFunctionPublicDir, absolutePublicDir); | |
// Copy the private folder to the function directory | |
if (fs.existsSync(absolutePrivateDir)) { | |
fs.cpSync(absolutePrivateDir, absolutePrivateFunctionDir, { | |
recursive: true, | |
}); | |
} | |
// Assume that any user files in public do not need to be bundled | |
// with the lambda function but public/assets/*.js and css do. | |
// We'll also copy any html files to the function public folder | |
// for use as custom error pages. | |
fs.mkdirSync(absoluteFunctionPublicDir); | |
const publicAssetsDir = path.join(absolutePublicDir, assetsDir); | |
const files = fs | |
.readdirSync(publicAssetsDir) | |
.filter((file) => file.endsWith(".css") || file.endsWith(".js")); | |
for (const file of files) { | |
fs.cpSync( | |
path.join(publicAssetsDir, file), | |
path.join(absoluteFunctionPublicDir, assetsDir, file), | |
); | |
} | |
const htmlOrTxtFiles = fs | |
.readdirSync(absolutePublicDir) | |
.filter((file) => file.endsWith(".html") || file.endsWith(".txt")); | |
for (const file of htmlOrTxtFiles) { | |
fs.cpSync( | |
path.join(absolutePublicDir, file), | |
path.join(absoluteFunctionPublicDir, file), | |
); | |
} | |
}); | |
} | |
function buildPlan() { | |
return all([outputPath]).apply(([outputPath]) => { | |
const distDir = "dist"; | |
const functionDir = "function"; | |
const publicDir = "public"; | |
const relativePublicDir = path.join(distDir, "public"); | |
const relativeFunctionDir = path.join(distDir, functionDir); | |
const absoluteFunctionDir = path.join(outputPath, relativeFunctionDir); | |
const versionedSubDir = "assets"; // waku places versioned files in public/assets | |
// It would be nice for devs to be able to define cache headers for other files | |
// that were manually added to the publicDir. With Cloudflare Pages, you can create | |
// a _headers file which will be used to set headers for various routes. Maybe | |
// that same file format could be read here and applied to the Cloudfront cache behaviors | |
// or via code injected into the Cloudfront function. | |
// https://developers.cloudflare.com/pages/configuration/headers/ | |
// TODO maybe add a build arg for isStatic | |
// waku also has a dynamic/static config setting for pages | |
const isStatic = false; | |
const edge = false; // IIRC, Lambda@Edge doesn't support streaming, so we don't want it with React 19 | |
const serverConfig: Unwrap<FunctionArgs> = { | |
streaming: true, | |
...args.server?.function, | |
environment: args.environment, | |
memory: args.server?.memory, | |
architecture: args.server?.architecture, | |
bundle: absoluteFunctionDir, | |
handler: "serve-aws-lambda.handler", | |
}; | |
const plan: Plan = { | |
edge, | |
// Cloudfront doesn't automatically move the Host to X-Forwarded-Host?? | |
// It seems a bit unnecessary to run a cloudflare function on every | |
// request just to copy the header. TODO test to make sure it's needed | |
cloudFrontFunctions: { | |
server: { | |
injections: [ | |
useCloudFrontFunctionHostHeaderInjection(), | |
// Noting that Astro uses a custom cloudfront routing function | |
// to handle requests for static page vs server endpoint vs redirects | |
// useCloudFrontRoutingInjection(publicDir), | |
], | |
}, | |
serverHostOnly: { | |
injections: [useCloudFrontFunctionHostHeaderInjection()], | |
}, | |
}, | |
origins: { | |
staticsServer: { | |
s3: { | |
copy: [ | |
{ | |
from: relativePublicDir, | |
to: "", | |
cached: true, | |
versionedSubDir, | |
}, | |
], | |
}, | |
}, | |
}, | |
behaviors: [], | |
errorResponses: [], | |
}; | |
if (edge) { | |
plan.edgeFunctions = { | |
server: { | |
function: serverConfig, | |
}, | |
}; | |
plan.behaviors.push( | |
{ | |
cacheType: "server", | |
cfFunction: "server", | |
edgeFunction: "edgeServer", | |
origin: "staticsServer", | |
}, | |
...fs.readdirSync(relativePublicDir).map( | |
(item) => | |
({ | |
cacheType: "static", | |
pattern: fs | |
.statSync(path.join(relativePublicDir, item)) | |
.isDirectory() | |
? `${item}/*` | |
: item, | |
origin: "staticsServer", | |
}) as const, | |
), | |
); | |
} else { | |
if (isStatic) { | |
plan.behaviors.push({ | |
cacheType: "static", | |
cfFunction: "server", | |
origin: "staticsServer", | |
}); | |
} else { | |
// biome-ignore lint/style/noNonNullAssertion: defined in plan above | |
plan.cloudFrontFunctions!.imageServiceCfFunction = { | |
injections: [useCloudFrontFunctionHostHeaderInjection()], | |
}; | |
plan.origins.regionalServer = { | |
server: { | |
function: serverConfig, | |
}, | |
}; | |
plan.origins.fallthroughServer = { | |
group: { | |
primaryOriginName: "staticsServer", | |
fallbackOriginName: "regionalServer", | |
fallbackStatusCodes: [403, 404], | |
}, | |
}; | |
plan.behaviors.push( | |
{ | |
cacheType: "server", | |
cfFunction: "server", | |
origin: "fallthroughServer", | |
allowedMethods: ["GET", "HEAD", "OPTIONS"], | |
}, | |
{ | |
cacheType: "static", | |
pattern: `${versionedSubDir}/*`, | |
origin: "staticsServer", | |
}, | |
{ | |
cacheType: "server", | |
pattern: "_image", | |
cfFunction: "imageServiceCfFunction", | |
origin: "regionalServer", | |
allowedMethods: ["GET", "HEAD", "OPTIONS"], | |
}, | |
); | |
} | |
} | |
return validatePlan(plan); | |
}); | |
} | |
} | |
/** | |
* The URL of the Waku site. | |
* | |
* 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, this.devUrl]).apply( | |
// biome-ignore lint/style/noNonNullAssertion: devUrl should be non empty here | |
([domainUrl, url, dev]) => domainUrl ?? url ?? dev!, | |
); | |
} | |
/** | |
* The underlying [resources](/docs/components/#nodes) this component creates. | |
*/ | |
public get nodes() { | |
return { | |
/** | |
* The AWS Lambda server function that renders the site. | |
*/ | |
server: this.server, | |
/** | |
* 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:Waku"; | |
// @ts-expect-error | |
Waku.__pulumiType = __pulumiType; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment