1import * as Effect from "effect/Effect";
2import type * as Redacted from "effect/Redacted";
3import { createHash } from "node:crypto";
4import { AlchemyContext } from "../../AlchemyContext.ts";
5import * as Command from "../../Command/index.ts";
6import { toPath } from "../../FQN.ts";
7import type { Input } from "../../Input.ts";
8import * as Namespace from "../../Namespace.ts";
9import * as Output from "../../Output.ts";
10import { ProviderModePolicy } from "../../ProviderMode.ts";
11import { isResource } from "../../Resource.ts";
12import { Stack } from "../../Stack.ts";
13import { Stage } from "../../Stage.ts";
14import { Certificate } from "../ACM/Certificate.ts";
15import { CachePolicy } from "../CloudFront/CachePolicy.ts";
16import { Distribution } from "../CloudFront/Distribution.ts";
17import { Function as CloudFrontFunction } from "../CloudFront/Function.ts";
18import { Invalidation } from "../CloudFront/Invalidation.ts";
19import { KeyValueStore } from "../CloudFront/KeyValueStore.ts";
20import { KvEntries } from "../CloudFront/KvEntries.ts";
21import { KvRoutesUpdate } from "../CloudFront/KvRoutesUpdate.ts";
22import {
23 MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_POLICY_ID,
24 MANAGED_CACHING_OPTIMIZED_POLICY_ID,
25} from "../CloudFront/ManagedPolicies.ts";
26import { OriginAccessControl } from "../CloudFront/OriginAccessControl.ts";
27import type { PolicyStatement } from "../IAM/Policy.ts";
28import { Record as Route53Record } from "../Route53/Record.ts";
29import { Bucket } from "../S3/Bucket.ts";
30import { AssetDeployment } from "./AssetDeployment.ts";
31import { buildHostRedirectInjection, CF_ROUTER_INJECTION } from "./cfcode.ts";
32import { asRouterDomain, registerDevRouterRoute } from "./DevRouterRoute.ts";
33import {
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
44export 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 */
256export 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 */
303export 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 */
327export 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 */
852const 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
915interface 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
942const 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";
955async 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
980const buildResponseFunctionCode = (
981 userInjection?: string,
982) => `import cf from "cloudfront";
983async function handler(event) {
984 ${userInjection ?? ""}
985 return event.response;
986}`;
987
988const normalizePrefix = (prefix: string | undefined) =>
989 prefix ? prefix.replace(/^\/+|\/+$/g, "") : "";
990
991const 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
1001const 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 */
1009const toHostPatternRegex = (pattern: string) =>
1010 pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");