1import type * as cloudfront from "@distilled.cloud/aws/cloudfront";
2import * as Effect from "effect/Effect";
3import type { Input } from "../../Input.ts";
4import * as Namespace from "../../Namespace.ts";
5import * as Output from "../../Output.ts";
6import { Certificate } from "../ACM/Certificate.ts";
7import { Distribution } from "../CloudFront/Distribution.ts";
8import { Invalidation } from "../CloudFront/Invalidation.ts";
9import {
10 MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_POLICY_ID,
11 MANAGED_CACHING_DISABLED_POLICY_ID,
12 MANAGED_CACHING_OPTIMIZED_POLICY_ID,
13} from "../CloudFront/ManagedPolicies.ts";
14import { OriginAccessControl } from "../CloudFront/OriginAccessControl.ts";
15import type { Service } from "../ECS/Service.ts";
16import type { PolicyStatement } from "../IAM/Policy.ts";
17import { Function } from "../Lambda/Function.ts";
18import { Record as Route53Record } from "../Route53/Record.ts";
19import { Bucket } from "../S3/Bucket.ts";
20import type { AssetFileOption } from "./AssetDeployment.ts";
21import { AssetDeployment } from "./AssetDeployment.ts";
22import {
23 normalizeWebsiteDomain,
24 type SsrSiteRouteTargets,
25 type WebsiteInvalidationProps,
26 type WebsiteStandaloneDomainProps,
27} from "./shared.ts";
28
29/**
30 * The dynamic origin CloudFront forwards requests to: a Lambda Function URL,
31 * a public ECS Service, or any plain URL.
32 */
33export type SsrSiteServerOrigin =
34 | {
35 type: "lambda";
36 /**
37 * Lambda Function created with `functionUrl` enabled — its function URL
38 * becomes the origin.
39 */
40 function: Function;
41 /**
42 * Protocol CloudFront uses to reach the origin.
43 * @default "https-only"
44 */
45 originProtocolPolicy?: "https-only";
46 }
47 | {
48 type: "ecs";
49 /**
50 * ECS Service created with `public: true` — its load balancer URL
51 * becomes the origin.
52 */
53 service: Service;
54 /**
55 * Protocol CloudFront uses to reach the origin.
56 * @default "https-only"
57 */
58 originProtocolPolicy?: "http-only" | "https-only" | "match-viewer";
59 }
60 | {
61 type: "url";
62 /**
63 * Origin URL to forward requests to.
64 */
65 url: Input<string>;
66 /**
67 * Protocol CloudFront uses to reach the origin.
68 * @default "https-only"
69 */
70 originProtocolPolicy?: cloudfront.OriginProtocolPolicy;
71 };
72
73export interface SsrSiteProps {
74 /**
75 * Dynamic server origin behind CloudFront.
76 */
77 server: SsrSiteServerOrigin;
78 /**
79 * Optional custom domain managed through Route 53. A string is shorthand
80 * for `{ name }`; `null` explicitly clears a previously set domain.
81 */
82 domain?: string | WebsiteStandaloneDomainProps | null;
83 /**
84 * Optional static asset bundle to serve from S3.
85 */
86 assets?: {
87 /**
88 * Local build output directory to upload.
89 */
90 sourcePath: Input<string>;
91 /**
92 * Optional deterministic S3 bucket name.
93 */
94 bucketName?: string;
95 /**
96 * Optional asset key prefix.
97 */
98 prefix?: string;
99 /**
100 * Path pattern that should be served from the asset bucket.
101 * @default "/_assets/*"
102 */
103 pathPattern?: string;
104 /**
105 * Remove stale files under the prefix.
106 * @default false
107 */
108 purge?: boolean;
109 /**
110 * Optional file overrides.
111 */
112 fileOptions?: AssetFileOption[];
113 };
114 /**
115 * CloudFront cache policy ID for the dynamic server route.
116 * @default CloudFront managed CachingDisabled
117 */
118 cachePolicyId?: Input<string>;
119 /**
120 * Cache invalidation behavior for asset updates.
121 * @default false
122 */
123 invalidate?: false | WebsiteInvalidationProps;
124 /**
125 * Whether to create a standalone CloudFront distribution for the site.
126 * Set this to `false` when the site should be routed through `AWS.Website.Router`.
127 * @default true
128 */
129 cdn?: boolean;
130 /**
131 * User-defined tags applied to created resources.
132 */
133 tags?: Record<string, string>;
134}
135
136const serverUrlOf = (server: SsrSiteServerOrigin): Input<string> => {
137 switch (server.type) {
138 case "lambda":
139 return Output.map((url: string | undefined) => {
140 if (!url) {
141 throw new Error(
142 "SsrSite lambda origins require a function created with `functionUrl` enabled.",
143 );
144 }
145 return url;
146 })(server.function.functionUrl as any) as any;
147 case "ecs":
148 return Output.map((url: string | undefined) => {
149 if (!url) {
150 throw new Error(
151 "SsrSite ECS origins require a service created with `public: true`.",
152 );
153 }
154 return url;
155 })(server.service.url as any) as any;
156 case "url":
157 return server.url;
158 }
159};
160
161const serverOriginOf = (server: SsrSiteServerOrigin): Input<string> =>
162 Output.map((url: string) => new URL(url).hostname)(
163 serverUrlOf(server) as any,
164 ) as any;
165
166/**
167 * A server-rendered website behind CloudFront.
168 *
169 * `SsrSite` serves a dynamic origin behind CloudFront and can optionally split
170 * immutable static assets into a private S3 bucket origin.
171 * ### Creating SSR Sites
172 * **Example:** Lambda URL Origin
173 * ```typescript
174 * const site = yield* SsrSite("App", {
175 * server: {
176 * type: "lambda",
177 * function: appFunction,
178 * },
179 * });
180 * ```
181 *
182 * **Example:** SSR With Static Assets
183 * ```typescript
184 * const site = yield* SsrSite("App", {
185 * server: {
186 * type: "lambda",
187 * function: appFunction,
188 * },
189 * assets: {
190 * sourcePath: "./dist/client",
191 * },
192 * });
193 * ```
194 *
195 * ### Custom Domains
196 * **Example:** SSR Site With A Route 53 Domain
197 * ```typescript
198 * const site = yield* SsrSite("App", {
199 * server: {
200 * type: "ecs",
201 * service: webService,
202 * },
203 * domain: {
204 * name: "app.example.com",
205 * hostedZoneId: zone.hostedZoneId,
206 * },
207 * });
208 * ```
209 *
210 * ### Router Composition
211 * **Example:** Route Through An Existing Router
212 * ```typescript
213 * // Skip the standalone distribution and register the returned
214 * // routeTargets on an AWS.Website.Router instead.
215 * const site = yield* SsrSite("App", {
216 * server: {
217 * type: "lambda",
218 * function: appFunction,
219 * },
220 * cdn: false,
221 * });
222 *
223 * const router = yield* AWS.Website.Router("WebsiteRouter", {
224 * routes: {
225 * "/*": site.routeTargets.server,
226 * },
227 * });
228 * ```
229 *
230 * @resource
231 */
232export const SsrSite = (id: string, props: SsrSiteProps) =>
233 Effect.gen(function* () {
234 const domain = normalizeWebsiteDomain(props.domain);
235 const assetPattern = props.assets?.pathPattern ?? "/_assets/*";
236 const serverUrl = serverUrlOf(props.server);
237 const serverOriginHost = serverOriginOf(props.server);
238
239 const assetBucket = props.assets
240 ? yield* Bucket("AssetsBucket", {
241 bucketName: props.assets.bucketName,
242 tags: props.tags,
243 })
244 : undefined;
245
246 const assetFiles =
247 props.assets && assetBucket
248 ? yield* AssetDeployment("AssetsFiles", {
249 bucket: assetBucket,
250 sourcePath: props.assets.sourcePath,
251 prefix: props.assets.prefix,
252 purge: props.assets.purge ?? false,
253 fileOptions: props.assets.fileOptions,
254 })
255 : undefined;
256
257 const assetOac =
258 props.assets && assetBucket
259 ? yield* OriginAccessControl("AssetsOriginAccessControl", {
260 originType: "s3",
261 description: `${id} SSR asset origin access control`,
262 })
263 : undefined;
264
265 const routeTargets: SsrSiteRouteTargets = {
266 server: {
267 url: serverUrl,
268 originProtocolPolicy: props.server.originProtocolPolicy ?? "https-only",
269 },
270 assets:
271 assetBucket && assetOac
272 ? {
273 pattern: assetPattern,
274 route: {
275 bucket: assetBucket,
276 originAccessControlId: assetOac.originAccessControlId,
277 originPath: props.assets?.prefix,
278 version: assetFiles?.version,
279 },
280 }
281 : undefined,
282 };
283
284 if (props.cdn === false) {
285 return {
286 assetBucket,
287 assetFiles,
288 assetOriginAccessControl: assetOac,
289 certificate: undefined,
290 distribution: undefined,
291 records: [],
292 invalidation: undefined,
293 routeTargets,
294 url: undefined,
295 urls: [] as Input<string>[],
296 };
297 }
298
299 if (domain && domain.dns === false && !domain.cert) {
300 return yield* Effect.fail(
301 new Error(
302 "SsrSite domain configuration with `dns: false` requires `cert`.",
303 ),
304 );
305 }
306
307 const certificate =
308 !domain || domain.cert
309 ? domain?.cert
310 ? { certificateArn: domain.cert }
311 : undefined
312 : yield* Certificate("Certificate", {
313 domainName: domain.name,
314 subjectAlternativeNames: [
315 ...(domain.aliases ?? []),
316 ...(domain.redirects ?? []),
317 ],
318 hostedZoneId: domain.hostedZoneId,
319 tags: props.tags,
320 });
321
322 const distribution = yield* Distribution("Distribution", {
323 aliases: domain ? [domain.name, ...(domain.aliases ?? [])] : undefined,
324 origins: [
325 {
326 id: "server",
327 domainName: serverOriginHost,
328 customOriginConfig: {
329 originProtocolPolicy:
330 props.server.originProtocolPolicy ?? "https-only",
331 },
332 },
333 ...(assetBucket && assetOac
334 ? [
335 {
336 id: "assets",
337 domainName: assetBucket.bucketRegionalDomainName,
338 originPath: props.assets?.prefix,
339 s3Origin: true,
340 originAccessControlId: assetOac.originAccessControlId,
341 },
342 ]
343 : []),
344 ],
345 defaultCacheBehavior: {
346 targetOriginId: "server",
347 viewerProtocolPolicy: "redirect-to-https",
348 compress: true,
349 allowedMethods: [
350 "DELETE",
351 "GET",
352 "HEAD",
353 "OPTIONS",
354 "PATCH",
355 "POST",
356 "PUT",
357 ],
358 cachedMethods: ["GET", "HEAD"],
359 cachePolicyId:
360 props.cachePolicyId ?? MANAGED_CACHING_DISABLED_POLICY_ID,
361 originRequestPolicyId: MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_POLICY_ID,
362 },
363 orderedCacheBehaviors:
364 assetBucket && assetOac
365 ? [
366 {
367 pathPattern: assetPattern,
368 targetOriginId: "assets",
369 viewerProtocolPolicy: "redirect-to-https",
370 compress: true,
371 allowedMethods: ["GET", "HEAD", "OPTIONS"],
372 cachedMethods: ["GET", "HEAD"],
373 cachePolicyId: MANAGED_CACHING_OPTIMIZED_POLICY_ID,
374 },
375 ]
376 : undefined,
377 viewerCertificate: certificate
378 ? {
379 acmCertificateArn: (certificate as any).certificateArn,
380 sslSupportMethod: "sni-only",
381 minimumProtocolVersion: "TLSv1.2_2021",
382 }
383 : undefined,
384 tags: props.tags,
385 });
386
387 if (assetBucket && assetOac) {
388 const bucketPolicy: PolicyStatement = {
389 Effect: "Allow",
390 Principal: {
391 Service: "cloudfront.amazonaws.com",
392 },
393 Action: ["s3:GetObject"],
394 Resource: [Output.interpolate`${assetBucket.bucketArn}/*` as any],
395 Condition: {
396 StringEquals: {
397 "AWS:SourceArn": distribution.distributionArn as any,
398 },
399 },
400 };
401
402 yield* assetBucket.bind`AWS.S3.Policy(${distribution}, ${assetBucket})`({
403 policyStatements: [bucketPolicy],
404 });
405 }
406
407 const records =
408 domain && domain.dns !== false
409 ? yield* Effect.forEach(
410 [
411 domain.name,
412 ...(domain.aliases ?? []),
413 ...(domain.redirects ?? []),
414 ],
415 (name, index) =>
416 Route53Record(`AliasRecord${index + 1}`, {
417 // Optional — the Record provider infers the most specific
418 // public zone containing `name` when omitted.
419 hostedZoneId: domain.hostedZoneId,
420 name,
421 type: "A",
422 aliasTarget: {
423 hostedZoneId: distribution.hostedZoneId,
424 dnsName: distribution.domainName,
425 },
426 }),
427 { concurrency: "unbounded" },
428 )
429 : [];
430
431 const invalidation =
432 props.invalidate === false || !assetFiles
433 ? undefined
434 : yield* Invalidation("Invalidation", {
435 distributionId: distribution.distributionId,
436 version: assetFiles.version,
437 wait: props.invalidate?.wait,
438 paths:
439 props.invalidate?.paths === "versioned"
440 ? [assetPattern]
441 : props.invalidate?.paths === "all" || !props.invalidate?.paths
442 ? ["/*"]
443 : props.invalidate.paths,
444 });
445
446 // Precedence: the canonical domain, then aliases in declaration order,
447 // then the CloudFront default domain. Redirect hostnames never appear.
448 const urls: Input<string>[] = domain
449 ? [
450 Output.interpolate`https://${domain.name}`,
451 ...(domain.aliases ?? []).map((alias) => `https://${alias}`),
452 Output.interpolate`https://${distribution.domainName}`,
453 ]
454 : [Output.interpolate`https://${distribution.domainName}`];
455
456 return {
457 assetBucket,
458 assetFiles,
459 assetOriginAccessControl: assetOac,
460 certificate,
461 distribution,
462 records,
463 invalidation,
464 routeTargets,
465 /**
466 * The most significant URL the site serves at — always `urls[0]`.
467 */
468 url: urls[0],
469 /**
470 * Every URL that serves this site, most significant first —
471 * `[https://<domain.name>?, ...aliases, <CloudFront default
472 * domain>]`. Redirect hostnames never appear — they serve no
473 * content.
474 */
475 urls,
476 };
477 }).pipe(Namespace.push(id));