| 1 | import * as Effect from "effect/Effect"; |
| 2 | import type * as Redacted from "effect/Redacted"; |
| 3 | import { createHash } from "node:crypto"; |
| 4 | import { AlchemyContext } from "../../AlchemyContext.ts"; |
| 5 | import * as Command from "../../Command/index.ts"; |
| 6 | import { toPath } from "../../FQN.ts"; |
| 7 | import type { Input } from "../../Input.ts"; |
| 8 | import * as Namespace from "../../Namespace.ts"; |
| 9 | import * as Output from "../../Output.ts"; |
| 10 | import { ProviderModePolicy } from "../../ProviderMode.ts"; |
| 11 | import { isResource } from "../../Resource.ts"; |
| 12 | import { Stack } from "../../Stack.ts"; |
| 13 | import { Stage } from "../../Stage.ts"; |
| 14 | import { Certificate } from "../ACM/Certificate.ts"; |
| 15 | import { CachePolicy } from "../CloudFront/CachePolicy.ts"; |
| 16 | import { Distribution } from "../CloudFront/Distribution.ts"; |
| 17 | import { Function as CloudFrontFunction } from "../CloudFront/Function.ts"; |
| 18 | import { Invalidation } from "../CloudFront/Invalidation.ts"; |
| 19 | import { KeyValueStore } from "../CloudFront/KeyValueStore.ts"; |
| 20 | import { KvEntries } from "../CloudFront/KvEntries.ts"; |
| 21 | import { KvRoutesUpdate } from "../CloudFront/KvRoutesUpdate.ts"; |
| 22 | import { |
| 23 | MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_POLICY_ID, |
| 24 | MANAGED_CACHING_OPTIMIZED_POLICY_ID, |
| 25 | } from "../CloudFront/ManagedPolicies.ts"; |
| 26 | import { OriginAccessControl } from "../CloudFront/OriginAccessControl.ts"; |
| 27 | import type { PolicyStatement } from "../IAM/Policy.ts"; |
| 28 | import { Record as Route53Record } from "../Route53/Record.ts"; |
| 29 | import { Bucket } from "../S3/Bucket.ts"; |
| 30 | import { AssetDeployment } from "./AssetDeployment.ts"; |
| 31 | import { buildHostRedirectInjection, CF_ROUTER_INJECTION } from "./cfcode.ts"; |
| 32 | import { asRouterDomain, registerDevRouterRoute } from "./DevRouterRoute.ts"; |
| 33 | import { |
| 34 | normalizeWebsiteDomain, |
| 35 | type StaticSiteBuildProps, |
| 36 | type WebsiteAssetsConfig, |
| 37 | type WebsiteDomainProps, |
| 38 | type WebsiteEdgeProps, |
| 39 | type WebsiteInvalidationProps, |
| 40 | type WebsiteRouterDomainProps, |
| 41 | type WebsiteStandaloneDomainProps, |
| 42 | } from "./shared.ts"; |
| 43 | |
| 44 | export interface StaticSiteProps { |
| 45 | /** |
| 46 | * Path to the local site directory. |
| 47 | * @default "." |
| 48 | */ |
| 49 | path?: Input<string>; |
| 50 | /** |
| 51 | * Optional build configuration executed before upload. |
| 52 | */ |
| 53 | build?: StaticSiteBuildProps; |
| 54 | /** |
| 55 | * Static site asset upload configuration. |
| 56 | */ |
| 57 | assets?: WebsiteAssetsConfig; |
| 58 | /** |
| 59 | * Optional custom domain. A string is shorthand for `{ name }`; `null` |
| 60 | * explicitly clears a previously set domain. Set `domain.router` to |
| 61 | * serve the site through an existing `AWS.Website.Router` instead of a |
| 62 | * standalone CloudFront distribution. |
| 63 | */ |
| 64 | domain?: string | WebsiteDomainProps | null; |
| 65 | /** |
| 66 | * Serve the site at its CloudFront default domain |
| 67 | * (`https://dxxxx.cloudfront.net`). The default domain cannot be removed |
| 68 | * from a distribution, so `false` is emulated at the edge: the generated |
| 69 | * viewer-request CloudFront Function 301s requests that arrive on the |
| 70 | * default domain to `https://<domain.name>` (path and query preserved), |
| 71 | * and the default domain is excluded from the `urls` output. |
| 72 | * |
| 73 | * Requires `domain` when `false` (the site would be unreachable). Not |
| 74 | * applicable to Router-attached sites (`domain.router`) — they own no |
| 75 | * distribution. |
| 76 | * @default true |
| 77 | */ |
| 78 | cloudfrontUrl?: boolean; |
| 79 | /** |
| 80 | * Additional CloudFront Function customizations. |
| 81 | */ |
| 82 | edge?: WebsiteEdgeProps; |
| 83 | /** |
| 84 | * Index page served for the site root. |
| 85 | * @default "index.html" |
| 86 | */ |
| 87 | indexPage?: string; |
| 88 | /** |
| 89 | * Serve this site as a single-page application: any request that does not |
| 90 | * match an uploaded file is answered with the `indexPage` and a `200` |
| 91 | * status so client-side routing can take over. |
| 92 | * |
| 93 | * This is also the fallback behavior when neither `spa` nor `errorPage` |
| 94 | * is set. Setting `spa: true` makes the intent explicit and guards |
| 95 | * against accidentally combining it with `errorPage`. |
| 96 | * |
| 97 | * Mutually exclusive with `errorPage` (a static site returns a real |
| 98 | * `404`; a SPA serves the app shell). |
| 99 | * @default false |
| 100 | */ |
| 101 | spa?: boolean; |
| 102 | /** |
| 103 | * Error page returned for 403/404 requests. |
| 104 | * When set, CloudFront customErrorResponses are created and misses return |
| 105 | * a real `404` status. Mutually exclusive with `spa`. |
| 106 | */ |
| 107 | errorPage?: string; |
| 108 | /** |
| 109 | * Optional deterministic S3 bucket name for newly created buckets. |
| 110 | */ |
| 111 | bucketName?: string; |
| 112 | /** |
| 113 | * Whether to delete uploaded objects before destroying created buckets. |
| 114 | * @default false |
| 115 | */ |
| 116 | forceDestroy?: boolean; |
| 117 | /** |
| 118 | * CloudFront invalidation behavior. |
| 119 | * @default { paths: "all", wait: false } |
| 120 | */ |
| 121 | invalidation?: false | WebsiteInvalidationProps; |
| 122 | /** |
| 123 | * User-defined tags applied to created resources. |
| 124 | */ |
| 125 | tags?: Record<string, string>; |
| 126 | /** |
| 127 | * Local dev configuration. When `alchemy dev` runs, the build/upload is |
| 128 | * skipped and `command` is spawned as a long-lived child process tied to |
| 129 | * the stack's scope. Alchemy does not proxy or interpret the process — |
| 130 | * the dev server's own URL (e.g. `http://localhost:5173`) is what you |
| 131 | * open in the browser. |
| 132 | * |
| 133 | * @example |
| 134 | * ```typescript |
| 135 | * AWS.Website.StaticSite("App", { |
| 136 | * path: "./app", |
| 137 | * build: { command: "npm run build", output: "dist" }, |
| 138 | * dev: { command: "npm run dev" }, |
| 139 | * }); |
| 140 | * ``` |
| 141 | */ |
| 142 | dev?: { |
| 143 | /** |
| 144 | * Shell command to run as the local dev server (e.g. `npm run dev`). |
| 145 | */ |
| 146 | command: string; |
| 147 | /** |
| 148 | * Working directory for {@link command}. Defaults to |
| 149 | * {@link StaticSiteProps.path} (the site directory), or |
| 150 | * `process.cwd()` if neither is set. |
| 151 | */ |
| 152 | cwd?: string; |
| 153 | /** |
| 154 | * Environment variables for {@link command}, merged on top of |
| 155 | * `process.env`. `Redacted` values stay out of logs and state, so put |
| 156 | * secrets here rather than interpolating them into {@link command}. |
| 157 | */ |
| 158 | env?: Record<string, string | Redacted.Redacted<string>>; |
| 159 | /** |
| 160 | * Override for the `url` output if alchemy fails to detect it from the |
| 161 | * stdout of the dev command. |
| 162 | */ |
| 163 | url?: string; |
| 164 | }; |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * Deploy a static website to S3 and CloudFront using KV-based edge routing. |
| 169 | * |
| 170 | * `StaticSite` uploads site files to a private S3 bucket, creates a CloudFront |
| 171 | * KeyValueStore with a file manifest for edge routing, and optionally builds |
| 172 | * the site first. Supports standalone distribution or composition with |
| 173 | * `AWS.Website.Router`. |
| 174 | * ### Basic Sites |
| 175 | * **Example:** Simple Static Site |
| 176 | * ```typescript |
| 177 | * const site = yield* StaticSite("Docs", { |
| 178 | * path: "./site", |
| 179 | * }); |
| 180 | * ``` |
| 181 | * |
| 182 | * ### Built Sites |
| 183 | * **Example:** Build A Vite App |
| 184 | * ```typescript |
| 185 | * const site = yield* StaticSite("Web", { |
| 186 | * path: "./frontend", |
| 187 | * build: { |
| 188 | * command: "bun run build", |
| 189 | * output: "dist", |
| 190 | * env: { |
| 191 | * VITE_API_URL: api.url, |
| 192 | * }, |
| 193 | * }, |
| 194 | * }); |
| 195 | * ``` |
| 196 | * |
| 197 | * ### Single-Page Applications |
| 198 | * **Example:** SPA With Client-Side Routing |
| 199 | * ```typescript |
| 200 | * // Misses fall back to index.html with a 200 so the client router |
| 201 | * // can handle the path. |
| 202 | * const site = yield* StaticSite("App", { |
| 203 | * path: "./app", |
| 204 | * build: { |
| 205 | * command: "bun run build", |
| 206 | * output: "dist", |
| 207 | * }, |
| 208 | * spa: true, |
| 209 | * }); |
| 210 | * ``` |
| 211 | * |
| 212 | * ### Custom Domains |
| 213 | * **Example:** Site With A Route 53 Domain |
| 214 | * ```typescript |
| 215 | * const site = yield* StaticSite("Web", { |
| 216 | * path: "./site", |
| 217 | * domain: { |
| 218 | * name: "www.example.com", |
| 219 | * hostedZoneId: zone.hostedZoneId, |
| 220 | * }, |
| 221 | * errorPage: "404.html", |
| 222 | * }); |
| 223 | * ``` |
| 224 | * |
| 225 | * ### Router Composition |
| 226 | * **Example:** Serve Through A Router |
| 227 | * ```typescript |
| 228 | * const site = yield* StaticSite("Docs", { |
| 229 | * path: "./docs", |
| 230 | * domain: { |
| 231 | * router, |
| 232 | * path: "/docs", |
| 233 | * }, |
| 234 | * }); |
| 235 | * ``` |
| 236 | * |
| 237 | * **Example:** Host-Matched Router Attachment |
| 238 | * ```typescript |
| 239 | * // The site serves for docs.example.com on the router. On a same-stack |
| 240 | * // router that owns a domain, this declaration alone provisions the |
| 241 | * // hostname end-to-end: the site binds it onto the router's distribution |
| 242 | * // (alias), certificate (SAN), and Route 53 record set. Wildcard |
| 243 | * // patterns and cross-stack router refs register KV host-matching only — |
| 244 | * // those hostnames must be covered by the router's own domain. |
| 245 | * const site = yield* StaticSite("Docs", { |
| 246 | * path: "./docs", |
| 247 | * domain: { |
| 248 | * name: "docs.example.com", |
| 249 | * router, |
| 250 | * }, |
| 251 | * }); |
| 252 | * ``` |
| 253 | * |
| 254 | * @resource |
| 255 | */ |
| 256 | export const StaticSite = (id: string, props: StaticSiteProps) => |
| 257 | Effect.gen(function* () { |
| 258 | const ctx = yield* AlchemyContext; |
| 259 | const remoted = yield* ProviderModePolicy; |
| 260 | // Mirrors the Cloudflare Website composites: during `alchemy dev` with |
| 261 | // a `dev.command`, the site is the external dev server (spawned in the |
| 262 | // dev sidecar so it survives user-code HMR) and no cloud resources are |
| 263 | // declared; `Alchemy.remote()` opts back into the full live deployment. |
| 264 | const isLocal = ctx.dev && remoted !== true; |
| 265 | |
| 266 | if (isLocal && props.dev) { |
| 267 | const dev = yield* Command.Dev("Dev", { |
| 268 | command: props.dev.command, |
| 269 | cwd: |
| 270 | props.dev.cwd ?? |
| 271 | (typeof props.path === "string" ? props.path : undefined), |
| 272 | env: props.dev.env, |
| 273 | }); |
| 274 | const devUrl = Output.map(dev.url, (url) => url ?? props.dev?.url); |
| 275 | // A Router-attached site registers with the Router in dev exactly as it |
| 276 | // does live — same resource types, same ids, same route entries — with |
| 277 | // the local dev server standing in for the S3 origin. |
| 278 | const routerDomain = asRouterDomain(normalizeWebsiteDomain(props.domain)); |
| 279 | const kvNamespace = routerDomain |
| 280 | ? yield* registerDevRouterRoute(routerDomain, devUrl) |
| 281 | : undefined; |
| 282 | return { |
| 283 | bucket: undefined, |
| 284 | build: undefined, |
| 285 | files: undefined, |
| 286 | distribution: undefined, |
| 287 | invalidation: undefined, |
| 288 | kvNamespace, |
| 289 | url: devUrl, |
| 290 | urls: [devUrl], |
| 291 | }; |
| 292 | } |
| 293 | |
| 294 | return yield* makeKvSite(id, props); |
| 295 | }).pipe(Namespace.push(id)); |
| 296 | |
| 297 | /** |
| 298 | * Dynamic server origin for {@link makeKvSite} — the KV metadata gains a |
| 299 | * `servers` entry so requests that match no uploaded file are forwarded to |
| 300 | * the server instead of a static fallback. |
| 301 | * @internal |
| 302 | */ |
| 303 | export interface KvSiteServerOptions { |
| 304 | /** |
| 305 | * Hostname of the dynamic server origin (e.g. a Lambda Function URL |
| 306 | * host). Requests that miss the file manifest are forwarded here with |
| 307 | * `x-forwarded-host` set. |
| 308 | */ |
| 309 | serverHost: Input<string>; |
| 310 | /** |
| 311 | * Optional dedicated image-optimization origin: requests whose path |
| 312 | * starts with `route` (e.g. `/_next/image`) are forwarded to `host` |
| 313 | * instead of the server origin (see `metadata.image` in cfcode.ts). |
| 314 | */ |
| 315 | image?: { |
| 316 | route: string; |
| 317 | host: Input<string>; |
| 318 | }; |
| 319 | } |
| 320 | |
| 321 | /** |
| 322 | * Shared implementation behind `StaticSite` and the SSR framework |
| 323 | * composites (`AWS.Website.Nuxt`, ...): S3 + CloudFront + KV-manifest edge |
| 324 | * routing, optionally with a dynamic server origin for misses. |
| 325 | * @internal |
| 326 | */ |
| 327 | export const makeKvSite = Effect.fn("AWS.Website.KvSite")(function* ( |
| 328 | id: string, |
| 329 | props: StaticSiteProps, |
| 330 | server?: KvSiteServerOptions, |
| 331 | ) { |
| 332 | const domain = normalizeWebsiteDomain(props.domain); |
| 333 | const routerDomain = domain?.router |
| 334 | ? (domain as WebsiteRouterDomainProps) |
| 335 | : undefined; |
| 336 | const standaloneDomain = |
| 337 | domain && !domain.router |
| 338 | ? (domain as WebsiteStandaloneDomainProps) |
| 339 | : undefined; |
| 340 | const sitePath = (props.path ?? ".") as string; |
| 341 | const indexPage = props.indexPage ?? "index.html"; |
| 342 | const assetPrefix = normalizePrefix(props.assets?.path); |
| 343 | const assetRoutes = [...(props.assets?.routes ?? [])] |
| 344 | .map((value) => value.trim()) |
| 345 | .filter(Boolean) |
| 346 | .map(normalizeRoutePath); |
| 347 | const invalidationProps = |
| 348 | props.invalidation !== undefined |
| 349 | ? props.invalidation |
| 350 | : { paths: "all" as const, wait: false }; |
| 351 | |
| 352 | if (routerDomain && props.edge) { |
| 353 | return yield* Effect.die( |
| 354 | `Cannot provide both "edge" and "domain.router". Use the "edge" prop on the Router component.`, |
| 355 | ); |
| 356 | } |
| 357 | if (routerDomain && props.cloudfrontUrl !== undefined) { |
| 358 | return yield* Effect.die( |
| 359 | `"cloudfrontUrl" does not apply to a Router-attached site ("domain.router" is set): the site owns no distribution. Set "cloudfrontUrl" on the Router instead.`, |
| 360 | ); |
| 361 | } |
| 362 | if (props.cloudfrontUrl === false && !standaloneDomain) { |
| 363 | return yield* Effect.die( |
| 364 | `"cloudfrontUrl: false" requires a "domain" — without one the site would be unreachable (the CloudFront default domain is its only URL).`, |
| 365 | ); |
| 366 | } |
| 367 | if (routerDomain?.aliases?.length && !routerDomain.name) { |
| 368 | return yield* Effect.die( |
| 369 | `"domain.aliases" requires "domain.name" on a Router-attached site.`, |
| 370 | ); |
| 371 | } |
| 372 | if ( |
| 373 | routerDomain?.redirects?.length && |
| 374 | (!routerDomain.name || routerDomain.name.includes("*")) |
| 375 | ) { |
| 376 | return yield* Effect.die( |
| 377 | `"domain.redirects" requires a concrete (non-wildcard) "domain.name" to redirect to.`, |
| 378 | ); |
| 379 | } |
| 380 | if ( |
| 381 | standaloneDomain?.redirects?.length && |
| 382 | standaloneDomain.name.includes("*") |
| 383 | ) { |
| 384 | return yield* Effect.die( |
| 385 | `"domain.redirects" requires a concrete (non-wildcard) "domain.name" to redirect to.`, |
| 386 | ); |
| 387 | } |
| 388 | if (props.spa && props.errorPage) { |
| 389 | return yield* Effect.die( |
| 390 | `Cannot provide both "spa" and "errorPage". A SPA answers misses with the index page (200); "errorPage" answers them with a real 404.`, |
| 391 | ); |
| 392 | } |
| 393 | if (server && (props.spa || props.errorPage)) { |
| 394 | return yield* Effect.die( |
| 395 | `A site with a server origin routes misses to the server; "spa" and "errorPage" do not apply.`, |
| 396 | ); |
| 397 | } |
| 398 | |
| 399 | const build = props.build |
| 400 | ? yield* Command.Build("Build", { |
| 401 | command: props.build.command, |
| 402 | cwd: sitePath, |
| 403 | memo: { |
| 404 | include: props.build.include, |
| 405 | exclude: props.build.exclude, |
| 406 | lockfile: props.build.lockfile, |
| 407 | }, |
| 408 | outdir: props.build.output, |
| 409 | env: props.build.env, |
| 410 | }) |
| 411 | : undefined; |
| 412 | |
| 413 | const uploadSourcePath = (build?.outdir ?? sitePath) as string; |
| 414 | |
| 415 | const providedBucket = props.assets?.bucket; |
| 416 | const bucket = |
| 417 | providedBucket ?? |
| 418 | (yield* Bucket("Bucket", { |
| 419 | bucketName: props.bucketName, |
| 420 | forceDestroy: props.forceDestroy, |
| 421 | tags: props.tags, |
| 422 | })); |
| 423 | |
| 424 | const routerPathPrefix = routerDomain?.path |
| 425 | ? "/" + routerDomain.path.replace(/^\//, "").replace(/\/$/, "") |
| 426 | : undefined; |
| 427 | |
| 428 | const files = yield* AssetDeployment("Files", { |
| 429 | bucket: bucket, |
| 430 | sourcePath: uploadSourcePath, |
| 431 | prefix: normalizeUploadPrefix(assetPrefix, routerPathPrefix), |
| 432 | purge: props.assets?.purge ?? true, |
| 433 | fileOptions: props.assets?.fileOptions, |
| 434 | textEncoding: props.assets?.textEncoding, |
| 435 | }); |
| 436 | |
| 437 | const stack = yield* Stack; |
| 438 | const stage = yield* Stage; |
| 439 | const ns = yield* Namespace.CurrentNamespace; |
| 440 | const fqn = ns ? toPath(ns).join("/") : id; |
| 441 | const kvNamespace = createHash("md5") |
| 442 | .update(`${stack.name}-${stage}-${fqn}`) |
| 443 | .digest("hex") |
| 444 | .substring(0, 4); |
| 445 | |
| 446 | // Standalone distributions carry the asset prefix as the default |
| 447 | // origin's `originPath` (so error-page fetches that bypass the edge |
| 448 | // function still resolve); router-attached sites prefix at the edge via |
| 449 | // the KV metadata instead. |
| 450 | const s3MetadataDir = routerDomain && assetPrefix ? "/" + assetPrefix : ""; |
| 451 | |
| 452 | const kvEntries = buildKvEntries({ |
| 453 | files, |
| 454 | bucketDomain: bucket.bucketRegionalDomainName as Input<string>, |
| 455 | s3Dir: s3MetadataDir, |
| 456 | assetRoutes, |
| 457 | indexPage, |
| 458 | errorPage: props.errorPage, |
| 459 | routerPathPrefix, |
| 460 | redirect: |
| 461 | routerDomain?.redirects?.length && routerDomain.name |
| 462 | ? { hosts: routerDomain.redirects, to: routerDomain.name } |
| 463 | : undefined, |
| 464 | serverHost: server?.serverHost, |
| 465 | imageRoute: server?.image?.route, |
| 466 | imageHost: server?.image?.host, |
| 467 | }); |
| 468 | |
| 469 | let distributionId: Input<string>; |
| 470 | let kvStoreArn: Input<string>; |
| 471 | let distribution: Distribution | undefined; |
| 472 | let urls: Input<string>[]; |
| 473 | |
| 474 | if (routerDomain) { |
| 475 | const routerRef = routerDomain.router; |
| 476 | kvStoreArn = routerRef.kvStoreArn; |
| 477 | distributionId = routerRef.distributionId; |
| 478 | // One KV route entry per host pattern: the canonical name (or the |
| 479 | // match-any-host "" pattern), each alias, and each redirect hostname |
| 480 | // (so redirected requests still match this site's route — the edge |
| 481 | // function then 301s them from the site's KV metadata). |
| 482 | const hostPatterns: [id: string, pattern: string | undefined][] = [ |
| 483 | ["RoutesUpdate", routerDomain.name], |
| 484 | ...(routerDomain.aliases ?? []).map((alias, index): [string, string] => [ |
| 485 | `RoutesUpdateAlias${index + 1}`, |
| 486 | alias, |
| 487 | ]), |
| 488 | ...(routerDomain.redirects ?? []).map( |
| 489 | (redirect, index): [string, string] => [ |
| 490 | `RoutesUpdateRedirect${index + 1}`, |
| 491 | redirect, |
| 492 | ], |
| 493 | ), |
| 494 | ]; |
| 495 | yield* Effect.forEach( |
| 496 | hostPatterns, |
| 497 | ([routeId, pattern]) => |
| 498 | KvRoutesUpdate(routeId, { |
| 499 | store: kvStoreArn, |
| 500 | namespace: routerRef.kvNamespace as any, |
| 501 | key: "routes", |
| 502 | entry: [ |
| 503 | "site", |
| 504 | kvNamespace, |
| 505 | pattern ? toHostPatternRegex(pattern) : "", |
| 506 | routerPathPrefix ?? "/", |
| 507 | ].join(","), |
| 508 | }), |
| 509 | { concurrency: "unbounded" }, |
| 510 | ); |
| 511 | // Site→Router hostname binding: this site's concrete hostnames are |
| 512 | // bound onto the Router's distribution (alias), managed certificate |
| 513 | // (SAN — a change replaces the certificate create-first), and Route 53 |
| 514 | // record set, so the declaration here alone fully provisions the |
| 515 | // hostname. Wildcard patterns bind nothing concrete, and cross-stack |
| 516 | // Router refs carry no `bindTargets` (bindings are same-stack) — in |
| 517 | // both cases the site registers KV host-matching only and the |
| 518 | // hostname must be covered by the Router's own `domain`. |
| 519 | const concreteHostnames = [ |
| 520 | ...(routerDomain.name && !routerDomain.name.includes("*") |
| 521 | ? [routerDomain.name] |
| 522 | : []), |
| 523 | ...(routerDomain.aliases ?? []).filter((alias) => !alias.includes("*")), |
| 524 | ...(routerDomain.redirects ?? []), |
| 525 | ]; |
| 526 | const bindTargets = routerRef.bindTargets; |
| 527 | if (bindTargets && concreteHostnames.length > 0) { |
| 528 | if (bindTargets.distribution && isResource(bindTargets.distribution)) { |
| 529 | yield* bindTargets.distribution.bind`AWS.Website.Site(${fqn})`({ |
| 530 | aliases: concreteHostnames, |
| 531 | }); |
| 532 | } |
| 533 | if (bindTargets.certificate && isResource(bindTargets.certificate)) { |
| 534 | yield* bindTargets.certificate.bind`AWS.Website.Site(${fqn})`({ |
| 535 | subjectAlternativeNames: concreteHostnames, |
| 536 | }); |
| 537 | } |
| 538 | if (bindTargets.records && isResource(bindTargets.records)) { |
| 539 | yield* bindTargets.records.bind`AWS.Website.Site(${fqn})`({ |
| 540 | names: concreteHostnames, |
| 541 | }); |
| 542 | } |
| 543 | } |
| 544 | // Host-matched attachment: the site's own hostnames (the router's |
| 545 | // CloudFront URL never serves it — KV host-match). Path-only |
| 546 | // attachment: derived from the router's primary URL, inheriting the |
| 547 | // router's precedence (including its `cloudfrontUrl` choice). |
| 548 | urls = routerDomain.name |
| 549 | ? [ |
| 550 | `https://${routerDomain.name}${routerPathPrefix ?? ""}`, |
| 551 | ...(routerDomain.aliases ?? []).map( |
| 552 | (alias) => `https://${alias}${routerPathPrefix ?? ""}`, |
| 553 | ), |
| 554 | ] |
| 555 | : [Output.interpolate`${routerRef.url}${routerPathPrefix ?? ""}`]; |
| 556 | } else { |
| 557 | const domain = standaloneDomain; |
| 558 | if ( |
| 559 | domain && |
| 560 | !domain.cert && |
| 561 | !domain.hostedZoneId && |
| 562 | domain.dns === false |
| 563 | ) { |
| 564 | return yield* Effect.die( |
| 565 | "StaticSite domain configuration with `dns: false` requires `cert`.", |
| 566 | ); |
| 567 | } |
| 568 | |
| 569 | const certificate = |
| 570 | !domain || domain.cert |
| 571 | ? domain?.cert |
| 572 | ? { certificateArn: domain.cert } |
| 573 | : undefined |
| 574 | : yield* Certificate("Certificate", { |
| 575 | domainName: domain.name, |
| 576 | subjectAlternativeNames: [ |
| 577 | ...(domain.aliases ?? []), |
| 578 | ...(domain.redirects ?? []), |
| 579 | ], |
| 580 | hostedZoneId: domain.hostedZoneId, |
| 581 | tags: props.tags, |
| 582 | }); |
| 583 | |
| 584 | const kvStore = yield* KeyValueStore("KvStore", {}); |
| 585 | kvStoreArn = kvStore.keyValueStoreArn; |
| 586 | |
| 587 | const viewerRequest = yield* CloudFrontFunction("ViewerRequest", { |
| 588 | comment: `${id} viewer request`, |
| 589 | code: buildRequestFunctionCode({ |
| 590 | kvNamespace, |
| 591 | userInjection: props.edge?.viewerRequest?.injection, |
| 592 | hostRedirect: domain |
| 593 | ? { |
| 594 | to: domain.name, |
| 595 | hosts: domain.redirects ?? [], |
| 596 | cloudfrontDefault: props.cloudfrontUrl === false, |
| 597 | } |
| 598 | : undefined, |
| 599 | }), |
| 600 | keyValueStoreArns: [kvStore.keyValueStoreArn], |
| 601 | }); |
| 602 | |
| 603 | const viewerResponse = props.edge?.viewerResponse |
| 604 | ? yield* CloudFrontFunction("ViewerResponse", { |
| 605 | comment: `${id} viewer response`, |
| 606 | code: buildResponseFunctionCode(props.edge.viewerResponse.injection), |
| 607 | keyValueStoreArns: props.edge.viewerResponse.keyValueStoreArn |
| 608 | ? [props.edge.viewerResponse.keyValueStoreArn] |
| 609 | : undefined, |
| 610 | }) |
| 611 | : undefined; |
| 612 | |
| 613 | const functionAssociations = [ |
| 614 | { |
| 615 | eventType: "viewer-request" as const, |
| 616 | functionArn: viewerRequest.functionArn, |
| 617 | }, |
| 618 | ...(viewerResponse |
| 619 | ? [ |
| 620 | { |
| 621 | eventType: "viewer-response" as const, |
| 622 | functionArn: viewerResponse.functionArn, |
| 623 | }, |
| 624 | ] |
| 625 | : []), |
| 626 | ]; |
| 627 | |
| 628 | const errorPage = "/" + (props.errorPage ?? indexPage).replace(/^\//, ""); |
| 629 | const customErrorResponses = |
| 630 | props.errorPage && !server |
| 631 | ? [ |
| 632 | { |
| 633 | ErrorCode: 403, |
| 634 | ResponseCode: "404", |
| 635 | ResponsePagePath: errorPage, |
| 636 | ErrorCachingMinTTL: 0, |
| 637 | }, |
| 638 | { |
| 639 | ErrorCode: 404, |
| 640 | ResponseCode: "404", |
| 641 | ResponsePagePath: errorPage, |
| 642 | ErrorCachingMinTTL: 0, |
| 643 | }, |
| 644 | ] |
| 645 | : undefined; |
| 646 | |
| 647 | // Server-backed sites share one behavior between S3 assets and the |
| 648 | // dynamic origin, so the cache policy must not cache responses that |
| 649 | // carry no Cache-Control (SSR pages) while still honoring the |
| 650 | // immutable Cache-Control the asset uploader sets. Managed |
| 651 | // CachingOptimized would cache header-less SSR responses for a day. |
| 652 | const serverCachePolicy = server |
| 653 | ? yield* CachePolicy("ServerCachePolicy", { |
| 654 | comment: `${id} server cache policy`, |
| 655 | minTTL: 0, |
| 656 | defaultTTL: 0, |
| 657 | maxTTL: "365 days", |
| 658 | parametersInCacheKeyAndForwardedToOrigin: { |
| 659 | EnableAcceptEncodingGzip: true, |
| 660 | EnableAcceptEncodingBrotli: true, |
| 661 | QueryStringsConfig: { QueryStringBehavior: "all" }, |
| 662 | HeadersConfig: { HeaderBehavior: "none" }, |
| 663 | CookiesConfig: { CookieBehavior: "none" }, |
| 664 | }, |
| 665 | }) |
| 666 | : undefined; |
| 667 | |
| 668 | // The default origin is real (not a placeholder): static sites point |
| 669 | // at the bucket through an OAC so requests that bypass the edge |
| 670 | // function's origin switch — CloudFront's custom-error-page fetches |
| 671 | // in particular — still resolve; server-backed sites point at the |
| 672 | // server so misses stream from it even if the function is bypassed. |
| 673 | const oac = server |
| 674 | ? undefined |
| 675 | : yield* OriginAccessControl("OriginAccessControl", { |
| 676 | originType: "s3", |
| 677 | description: `${id} origin access control`, |
| 678 | }); |
| 679 | |
| 680 | distribution = yield* Distribution("Distribution", { |
| 681 | aliases: domain |
| 682 | ? [domain.name, ...(domain.aliases ?? []), ...(domain.redirects ?? [])] |
| 683 | : undefined, |
| 684 | origins: [ |
| 685 | server |
| 686 | ? { |
| 687 | id: "default", |
| 688 | domainName: server.serverHost, |
| 689 | customOriginConfig: { |
| 690 | httpPort: 80, |
| 691 | httpsPort: 443, |
| 692 | originProtocolPolicy: "https-only" as const, |
| 693 | originReadTimeout: "20 seconds", |
| 694 | originSslProtocols: ["TLSv1.2"], |
| 695 | }, |
| 696 | } |
| 697 | : { |
| 698 | id: "default", |
| 699 | domainName: bucket.bucketRegionalDomainName, |
| 700 | s3Origin: true, |
| 701 | originAccessControlId: oac!.originAccessControlId, |
| 702 | originPath: assetPrefix ? "/" + assetPrefix : undefined, |
| 703 | }, |
| 704 | ], |
| 705 | defaultCacheBehavior: { |
| 706 | targetOriginId: "default", |
| 707 | viewerProtocolPolicy: "redirect-to-https", |
| 708 | allowedMethods: [ |
| 709 | "DELETE", |
| 710 | "GET", |
| 711 | "HEAD", |
| 712 | "OPTIONS", |
| 713 | "PATCH", |
| 714 | "POST", |
| 715 | "PUT", |
| 716 | ], |
| 717 | cachedMethods: ["GET", "HEAD"], |
| 718 | compress: true, |
| 719 | cachePolicyId: serverCachePolicy |
| 720 | ? serverCachePolicy.cachePolicyId |
| 721 | : MANAGED_CACHING_OPTIMIZED_POLICY_ID, |
| 722 | originRequestPolicyId: server |
| 723 | ? MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_POLICY_ID |
| 724 | : undefined, |
| 725 | functionAssociations, |
| 726 | }, |
| 727 | customErrorResponses, |
| 728 | viewerCertificate: certificate |
| 729 | ? { |
| 730 | acmCertificateArn: certificate.certificateArn, |
| 731 | sslSupportMethod: "sni-only", |
| 732 | minimumProtocolVersion: "TLSv1.2_2021", |
| 733 | } |
| 734 | : undefined, |
| 735 | tags: props.tags, |
| 736 | }); |
| 737 | |
| 738 | const dist = distribution; |
| 739 | distributionId = dist.distributionId; |
| 740 | |
| 741 | if (domain && domain.dns !== false) { |
| 742 | yield* Effect.forEach( |
| 743 | [domain.name, ...(domain.aliases ?? []), ...(domain.redirects ?? [])], |
| 744 | (name, index) => |
| 745 | Route53Record(`AliasRecord${index + 1}`, { |
| 746 | // Optional — the Record provider infers the most specific |
| 747 | // public zone containing `name` when omitted. |
| 748 | hostedZoneId: domain.hostedZoneId, |
| 749 | name, |
| 750 | type: "A", |
| 751 | aliasTarget: { |
| 752 | hostedZoneId: dist.hostedZoneId, |
| 753 | dnsName: dist.domainName, |
| 754 | }, |
| 755 | }), |
| 756 | { concurrency: "unbounded" }, |
| 757 | ); |
| 758 | } |
| 759 | |
| 760 | // Precedence: the canonical domain, then aliases in declaration |
| 761 | // order, then the CloudFront default domain (only while |
| 762 | // `cloudfrontUrl` is enabled). Redirect hostnames never appear. |
| 763 | // |
| 764 | // `dist.url` rather than an interpolated |
| 765 | // `https://${dist.domainName}` (same as Router): the distribution |
| 766 | // knows its own address, which is `https://{id}.cloudfront.net` on |
| 767 | // AWS and `http://localhost:{port}` under `alchemy dev`, where that |
| 768 | // AWS hostname resolves to nothing. |
| 769 | urls = domain |
| 770 | ? [ |
| 771 | Output.interpolate`https://${domain.name}`, |
| 772 | ...(domain.aliases ?? []).map((alias) => `https://${alias}`), |
| 773 | ...(props.cloudfrontUrl !== false ? [dist.url] : []), |
| 774 | ] |
| 775 | : [dist.url]; |
| 776 | } |
| 777 | |
| 778 | // The edge router signs S3 origin requests with OAC (sigv4, see |
| 779 | // `setS3Origin` in cfcode.ts), so the bucket must allow the serving |
| 780 | // distribution — without this policy every request 403s. |
| 781 | const servingDistributionArn = routerDomain |
| 782 | ? routerDomain.router.distributionArn |
| 783 | : distribution!.distributionArn; |
| 784 | const bucketPolicy: PolicyStatement = { |
| 785 | Effect: "Allow", |
| 786 | Principal: { |
| 787 | Service: "cloudfront.amazonaws.com", |
| 788 | }, |
| 789 | Action: ["s3:GetObject"], |
| 790 | Resource: [Output.interpolate`${bucket.bucketArn}/*` as any], |
| 791 | Condition: { |
| 792 | StringEquals: { |
| 793 | "AWS:SourceArn": servingDistributionArn as any, |
| 794 | }, |
| 795 | }, |
| 796 | }; |
| 797 | yield* bucket.bind`AWS.S3.Policy(CloudFront, ${bucket})`({ |
| 798 | policyStatements: [bucketPolicy], |
| 799 | }); |
| 800 | |
| 801 | yield* KvEntries("KvEntries", { |
| 802 | store: kvStoreArn, |
| 803 | namespace: kvNamespace, |
| 804 | entries: kvEntries, |
| 805 | purge: props.assets?.purge ?? true, |
| 806 | }); |
| 807 | |
| 808 | const invalidation = |
| 809 | invalidationProps === false |
| 810 | ? undefined |
| 811 | : yield* Invalidation("Invalidation", { |
| 812 | distributionId: distributionId, |
| 813 | version: files.version, |
| 814 | wait: invalidationProps?.wait, |
| 815 | paths: |
| 816 | invalidationProps?.paths === "all" || !invalidationProps?.paths |
| 817 | ? ["/*"] |
| 818 | : invalidationProps.paths === "versioned" |
| 819 | ? [`/${indexPage.replace(/^\/+/, "")}`] |
| 820 | : invalidationProps.paths, |
| 821 | }); |
| 822 | |
| 823 | return { |
| 824 | bucket: bucket, |
| 825 | build, |
| 826 | files, |
| 827 | distribution, |
| 828 | invalidation, |
| 829 | kvNamespace, |
| 830 | /** |
| 831 | * The most significant URL the site serves at — always `urls[0]`. |
| 832 | */ |
| 833 | url: urls[0], |
| 834 | /** |
| 835 | * Every URL that serves this site, most significant first — |
| 836 | * `[https://<domain.name>?, ...aliases, <CloudFront default domain>?]` |
| 837 | * (the default domain only while `cloudfrontUrl` is enabled). |
| 838 | * Router-attached sites list their own hostnames (host-matched) or |
| 839 | * the router's URL plus `domain.path` (path-only). Redirect |
| 840 | * hostnames never appear — they serve no content. |
| 841 | */ |
| 842 | urls, |
| 843 | }; |
| 844 | }); |
| 845 | |
| 846 | /** |
| 847 | * Derive the CloudFront KV routing entries from the *uploaded* file list |
| 848 | * (the `AssetDeployment`'s `files` attribute) so the manifest always |
| 849 | * reflects exactly what landed in S3 — including build outputs that do not |
| 850 | * exist at plan time. |
| 851 | */ |
| 852 | const buildKvEntries = (args: { |
| 853 | files: { files: Output.Output<string[]> }; |
| 854 | bucketDomain: Input<string>; |
| 855 | s3Dir: string; |
| 856 | assetRoutes: string[]; |
| 857 | indexPage: string; |
| 858 | errorPage: string | undefined; |
| 859 | routerPathPrefix: string | undefined; |
| 860 | redirect: { hosts: string[]; to: string } | undefined; |
| 861 | serverHost: Input<string> | undefined; |
| 862 | imageRoute: string | undefined; |
| 863 | imageHost: Input<string> | undefined; |
| 864 | }): Input<Record<string, string>> => |
| 865 | Output.map( |
| 866 | ([fileList, bucketDomain, serverHost, imageHost]: [ |
| 867 | string[] | undefined, |
| 868 | string, |
| 869 | string | undefined, |
| 870 | string | undefined, |
| 871 | ]) => { |
| 872 | const entries: Record<string, string> = {}; |
| 873 | for (const file of fileList ?? []) { |
| 874 | entries[`/${file}`] = "s3"; |
| 875 | } |
| 876 | const errorPagePath = |
| 877 | "/" + (args.errorPage ?? args.indexPage).replace(/^\//, ""); |
| 878 | const metadata: KvSiteMetadata = { |
| 879 | base: |
| 880 | args.routerPathPrefix && args.routerPathPrefix !== "/" |
| 881 | ? args.routerPathPrefix |
| 882 | : undefined, |
| 883 | custom404: |
| 884 | serverHost !== undefined || args.errorPage |
| 885 | ? undefined |
| 886 | : errorPagePath, |
| 887 | errorResponseCode: |
| 888 | serverHost === undefined && args.errorPage ? 404 : undefined, |
| 889 | s3: { |
| 890 | domain: bucketDomain, |
| 891 | dir: args.s3Dir, |
| 892 | routes: args.assetRoutes, |
| 893 | }, |
| 894 | servers: serverHost !== undefined ? [[serverHost]] : undefined, |
| 895 | image: |
| 896 | args.imageRoute !== undefined && imageHost !== undefined |
| 897 | ? { route: args.imageRoute, host: imageHost } |
| 898 | : undefined, |
| 899 | redirect: args.redirect, |
| 900 | }; |
| 901 | entries["metadata"] = JSON.stringify(metadata); |
| 902 | return entries; |
| 903 | }, |
| 904 | )( |
| 905 | Output.all( |
| 906 | args.files.files, |
| 907 | Output.asOutput(args.bucketDomain as any), |
| 908 | Output.asOutput(args.serverHost as any), |
| 909 | Output.asOutput(args.imageHost as any), |
| 910 | ) as Output.Output< |
| 911 | [string[] | undefined, string, string | undefined, string | undefined] |
| 912 | >, |
| 913 | ); |
| 914 | |
| 915 | interface KvSiteMetadata { |
| 916 | base?: string | undefined; |
| 917 | custom404?: string | undefined; |
| 918 | errorResponseCode?: number | undefined; |
| 919 | s3: { |
| 920 | domain: string; |
| 921 | dir: string; |
| 922 | routes: string[]; |
| 923 | }; |
| 924 | /** |
| 925 | * Server origin hosts (`[[host, lat?, lon?], ...]`) the edge router |
| 926 | * forwards misses to — see `findNearestServer` in cfcode.ts. |
| 927 | */ |
| 928 | servers?: Array<Array<string>> | undefined; |
| 929 | /** |
| 930 | * Dedicated image-optimization origin: requests whose path starts with |
| 931 | * `route` are forwarded to `host` — see `metadata.image` in cfcode.ts. |
| 932 | */ |
| 933 | image?: { route: string; host: string } | undefined; |
| 934 | /** |
| 935 | * Router-attached redirect hostnames: matched requests whose `Host` is |
| 936 | * in `hosts` are 301'd to `https://<to>` (path + query preserved) — see |
| 937 | * the `metadata.redirect` check in `routeSite` in cfcode.ts. |
| 938 | */ |
| 939 | redirect?: { hosts: string[]; to: string } | undefined; |
| 940 | } |
| 941 | |
| 942 | const buildRequestFunctionCode = ({ |
| 943 | kvNamespace, |
| 944 | userInjection, |
| 945 | hostRedirect, |
| 946 | }: { |
| 947 | kvNamespace: string; |
| 948 | userInjection?: string; |
| 949 | hostRedirect?: { |
| 950 | to: string; |
| 951 | hosts: string[]; |
| 952 | cloudfrontDefault: boolean; |
| 953 | }; |
| 954 | }) => `import cf from "cloudfront"; |
| 955 | async function handler(event) { |
| 956 | ${userInjection ?? ""} |
| 957 | ${ |
| 958 | hostRedirect |
| 959 | ? buildHostRedirectInjection({ |
| 960 | to: hostRedirect.to, |
| 961 | hosts: hostRedirect.hosts, |
| 962 | cloudfrontDefault: hostRedirect.cloudfrontDefault, |
| 963 | }) |
| 964 | : "" |
| 965 | } |
| 966 | ${CF_ROUTER_INJECTION} |
| 967 | |
| 968 | const kvNamespace = "${kvNamespace}"; |
| 969 | |
| 970 | let metadata; |
| 971 | try { |
| 972 | const v = await cf.kvs().get(kvNamespace + ":metadata"); |
| 973 | metadata = JSON.parse(v); |
| 974 | } catch (e) {} |
| 975 | |
| 976 | const response = await routeSite(kvNamespace, metadata); |
| 977 | return response || event.request; |
| 978 | }`; |
| 979 | |
| 980 | const buildResponseFunctionCode = ( |
| 981 | userInjection?: string, |
| 982 | ) => `import cf from "cloudfront"; |
| 983 | async function handler(event) { |
| 984 | ${userInjection ?? ""} |
| 985 | return event.response; |
| 986 | }`; |
| 987 | |
| 988 | const normalizePrefix = (prefix: string | undefined) => |
| 989 | prefix ? prefix.replace(/^\/+|\/+$/g, "") : ""; |
| 990 | |
| 991 | const normalizeUploadPrefix = ( |
| 992 | assetPrefix: string, |
| 993 | routerPathPrefix: string | undefined, |
| 994 | ) => { |
| 995 | const parts = [assetPrefix, routerPathPrefix?.replace(/^\//, "")].filter( |
| 996 | Boolean, |
| 997 | ); |
| 998 | return parts.join("/") || ""; |
| 999 | }; |
| 1000 | |
| 1001 | const normalizeRoutePath = (value: string) => |
| 1002 | `/${value.replace(/^\/+|\/+$/g, "")}`; |
| 1003 | |
| 1004 | /** |
| 1005 | * Convert a host pattern (`docs.example.com`, `*.example.com`) into the |
| 1006 | * escaped regex fragment stored in the Router's KV route table (matched by |
| 1007 | * the router's edge function). |
| 1008 | */ |
| 1009 | const toHostPatternRegex = (pattern: string) => |
| 1010 | pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); |