1import * as cloudfront from "@distilled.cloud/aws/cloudfront";
2import * as Data from "effect/Data";
3import type * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import * as FetchHttpClient from "effect/http/FetchHttpClient";
8import * as HttpClient from "effect/http/HttpClient";
9import { isResolved } from "../../Diff.ts";
10import type { Input } from "../../Input.ts";
11import * as Provider from "../../Provider.ts";
12import { toWireSeconds } from "../../Util/Duration.ts";
13import { Resource, type ResourceBinding } from "../../Resource.ts";
14import type { Providers } from "../Providers.ts";
15import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts";
16import { AWSEnvironment } from "../Environment.ts";
17
18const CLOUDFRONT_HOSTED_ZONE_ID = "Z2FDTNDATAQYW2" as const;
19
20class DistributionFunctionAssociationPending extends Data.TaggedError(
21 "DistributionFunctionAssociationPending",
22)<{
23 message: string;
24}> {}
25
26class DistributionPendingDeployment extends Data.TaggedError(
27 "DistributionPendingDeployment",
28)<{
29 message: string;
30}> {}
31
32export interface DistributionOrigin {
33 /**
34 * Unique origin identifier inside the distribution.
35 */
36 id: string;
37 /**
38 * Origin domain name.
39 */
40 domainName: Input<string>;
41 /**
42 * Optional origin path prefix.
43 */
44 originPath?: Input<string>;
45 /**
46 * CloudFront Origin Access Control identifier.
47 */
48 originAccessControlId?: Input<string>;
49 /**
50 * Whether the origin should be modeled as an S3 origin.
51 * @default false
52 */
53 s3Origin?: boolean;
54 /**
55 * Explicit S3 origin settings (legacy Origin Access Identity, read timeout).
56 * When set, the origin is treated as an S3 origin regardless of `s3Origin`.
57 */
58 s3OriginConfig?: {
59 originAccessIdentity?: string;
60 originReadTimeout?: Duration.Input;
61 };
62 /**
63 * Optional custom origin settings.
64 */
65 customOriginConfig?: {
66 httpPort?: number;
67 httpsPort?: number;
68 originProtocolPolicy?: cloudfront.OriginProtocolPolicy;
69 originReadTimeout?: Duration.Input;
70 originKeepaliveTimeout?: Duration.Input;
71 originSslProtocols?: cloudfront.SslProtocol[];
72 /**
73 * IP address type CloudFront uses to connect to the origin.
74 */
75 ipAddressType?: cloudfront.IpAddressType;
76 /**
77 * Mutual TLS configuration for the origin connection.
78 */
79 originMtlsConfig?: {
80 clientCertificateArn: string;
81 };
82 };
83 /**
84 * Route this origin through a VPC origin (private ALB/NLB/EC2). Mutually
85 * exclusive with `s3Origin`/`s3OriginConfig`/`customOriginConfig`.
86 */
87 vpcOriginConfig?: {
88 vpcOriginId: Input<string>;
89 originReadTimeout?: Duration.Input;
90 originKeepaliveTimeout?: Duration.Input;
91 ownerAccountId?: string;
92 };
93 /**
94 * Custom headers CloudFront adds to every request it sends to the origin.
95 */
96 customHeaders?: Record<string, string>;
97 /**
98 * Origin Shield configuration.
99 */
100 originShield?: {
101 enabled: boolean;
102 originShieldRegion?: string;
103 };
104 /**
105 * Number of times CloudFront attempts to connect to the origin (1-3).
106 */
107 connectionAttempts?: number;
108 /**
109 * How long CloudFront waits when trying to establish a connection (1-10
110 * seconds), e.g. `"5 seconds"` (a bare number is milliseconds).
111 */
112 connectionTimeout?: Duration.Input;
113 /**
114 * How long CloudFront waits for the origin to deliver a complete response,
115 * e.g. `"30 seconds"` (a bare number is milliseconds).
116 */
117 responseCompletionTimeout?: Duration.Input;
118}
119
120export interface DistributionBehavior {
121 /**
122 * ID of the origin (or origin group) this behavior routes requests to.
123 */
124 targetOriginId: string;
125 /**
126 * How viewers may connect (e.g. `redirect-to-https`, `https-only`).
127 */
128 viewerProtocolPolicy?: cloudfront.ViewerProtocolPolicy;
129 /**
130 * HTTP methods CloudFront accepts and forwards to the origin.
131 */
132 allowedMethods?: cloudfront.Method[];
133 /**
134 * HTTP methods whose responses CloudFront caches.
135 */
136 cachedMethods?: cloudfront.Method[];
137 /**
138 * Whether CloudFront automatically compresses eligible responses.
139 */
140 compress?: boolean;
141 /**
142 * Cache policy ID (a managed policy or a `CachePolicy`) controlling the
143 * cache key and TTLs.
144 */
145 cachePolicyId?: string;
146 /**
147 * Origin request policy ID controlling which viewer values CloudFront
148 * forwards to the origin.
149 */
150 originRequestPolicyId?: string;
151 /**
152 * Response headers policy ID applied to viewer responses.
153 */
154 responseHeadersPolicyId?: string;
155 /**
156 * Legacy forwarded-values settings. Prefer `cachePolicyId` /
157 * `originRequestPolicyId` for new configurations.
158 */
159 forwardedValues?: cloudfront.ForwardedValues;
160 /**
161 * Minimum time responses stay cached, e.g. `"1 hour"` (a bare number is
162 * milliseconds). Used with `forwardedValues`.
163 */
164 minTtl?: Duration.Input;
165 /**
166 * Default time responses stay cached when the origin sends no caching
167 * headers.
168 */
169 defaultTtl?: Duration.Input;
170 /**
171 * Maximum time responses stay cached.
172 */
173 maxTtl?: Duration.Input;
174 /**
175 * CloudFront Functions to run on viewer request/response events.
176 */
177 functionAssociations?: {
178 functionArn: string;
179 eventType: cloudfront.EventType;
180 }[];
181 /**
182 * Lambda@Edge functions to run on viewer/origin request/response events.
183 */
184 lambdaFunctionAssociations?: {
185 lambdaFunctionArn: string;
186 eventType: cloudfront.EventType;
187 includeBody?: boolean;
188 }[];
189 /**
190 * CloudFront KeyGroup IDs whose public keys gate signed URLs/cookies.
191 */
192 trustedKeyGroups?: Input<string[]>;
193 /**
194 * Legacy trusted signer AWS account numbers for signed URLs/cookies.
195 */
196 trustedSigners?: string[];
197 /**
198 * Field-level encryption configuration ID.
199 */
200 fieldLevelEncryptionId?: string;
201 /**
202 * ARN of a real-time log configuration to attach.
203 */
204 realtimeLogConfigArn?: string;
205 /**
206 * Whether Microsoft Smooth Streaming is enabled for this behavior.
207 */
208 smoothStreaming?: boolean;
209 /**
210 * gRPC configuration for this behavior.
211 */
212 grpcConfig?: {
213 enabled: boolean;
214 };
215}
216
217export interface DistributionViewerCertificate {
218 /**
219 * Serve HTTPS with the default `*.cloudfront.net` certificate (no custom
220 * domains).
221 */
222 cloudFrontDefaultCertificate?: boolean;
223 /**
224 * ARN of an ACM certificate (must live in `us-east-1`) covering the
225 * distribution's aliases.
226 */
227 acmCertificateArn?: string;
228 /**
229 * How CloudFront serves HTTPS to viewers (`sni-only` for modern clients).
230 */
231 sslSupportMethod?: cloudfront.SSLSupportMethod;
232 /**
233 * Minimum TLS protocol version viewers must support.
234 */
235 minimumProtocolVersion?: cloudfront.MinimumProtocolVersion;
236 /**
237 * Legacy IAM certificate ID.
238 */
239 iamCertificateId?: string;
240 /**
241 * Legacy certificate identifier (IAM/ACM raw value).
242 */
243 certificate?: string;
244 /**
245 * Source of the legacy certificate.
246 */
247 certificateSource?: cloudfront.CertificateSource;
248}
249
250export interface DistributionGeoRestriction {
251 /**
252 * Restriction mode. `none` disables geo restriction.
253 */
254 restrictionType: cloudfront.GeoRestrictionType;
255 /**
256 * Two-letter ISO 3166-1 country codes the restriction applies to.
257 */
258 locations?: string[];
259}
260
261export interface DistributionLogging {
262 /**
263 * Whether access logging is enabled.
264 * @default true
265 */
266 enabled?: boolean;
267 /**
268 * Whether cookies are included in access logs.
269 */
270 includeCookies?: boolean;
271 /**
272 * S3 bucket (domain name) that receives access logs.
273 */
274 bucket?: string;
275 /**
276 * Prefix applied to access log object keys.
277 */
278 prefix?: string;
279}
280
281export interface DistributionOriginGroup {
282 /**
283 * Origin group identifier (target it from a cache behavior).
284 */
285 id: string;
286 /**
287 * Member origin IDs in failover order (primary first, secondary second).
288 */
289 members: string[];
290 /**
291 * HTTP status codes that trigger failover to the next member.
292 */
293 failoverStatusCodes: number[];
294 /**
295 * How CloudFront selects the origin within the group.
296 */
297 selectionCriteria?: cloudfront.OriginGroupSelectionCriteria;
298}
299
300const isFunctionAssociationPending = (error: cloudfront.InvalidArgument) => {
301 const message = error.message ?? "";
302 return (
303 message.includes("FunctionAssociationArn") &&
304 message.includes("not found or is not published")
305 );
306};
307
308export interface DistributionProps {
309 /**
310 * Alternate domain names routed to this distribution.
311 */
312 aliases?: string[];
313 /**
314 * Default root object served for `/`.
315 */
316 defaultRootObject?: string;
317 /**
318 * CloudFront origin definitions.
319 */
320 origins: Input<DistributionOrigin[]>;
321 /**
322 * Default cache behavior.
323 */
324 defaultCacheBehavior: Input<DistributionBehavior>;
325 /**
326 * Ordered cache behaviors.
327 */
328 orderedCacheBehaviors?: Input<
329 Array<
330 DistributionBehavior & {
331 pathPattern: string;
332 }
333 >
334 >;
335 /**
336 * Custom error response rules.
337 */
338 customErrorResponses?: Input<cloudfront.CustomErrorResponse[]>;
339 /**
340 * Human-readable distribution comment.
341 * @default ""
342 */
343 comment?: string;
344 /**
345 * Whether the distribution should serve traffic.
346 * @default true
347 */
348 enabled?: boolean;
349 /**
350 * Viewer certificate configuration.
351 */
352 viewerCertificate?: Input<DistributionViewerCertificate>;
353 /**
354 * CloudFront price class.
355 */
356 priceClass?: cloudfront.PriceClass;
357 /**
358 * Optional AWS WAF web ACL association.
359 */
360 webAclId?: string;
361 /**
362 * Preferred HTTP version support.
363 */
364 httpVersion?: cloudfront.HttpVersion;
365 /**
366 * Whether IPv6 should be enabled.
367 * @default true
368 */
369 isIpv6Enabled?: boolean;
370 /**
371 * Geographic distribution restrictions. Defaults to no restriction.
372 */
373 geoRestriction?: DistributionGeoRestriction;
374 /**
375 * Standard access logging configuration.
376 */
377 logging?: DistributionLogging;
378 /**
379 * Origin failover groups. Target a group id from a cache behavior's
380 * `targetOriginId`.
381 */
382 originGroups?: Input<DistributionOriginGroup[]>;
383 /**
384 * Continuous deployment policy ID for blue/green deployments.
385 */
386 continuousDeploymentPolicyId?: string;
387 /**
388 * Whether this is a staging distribution for blue/green deployments.
389 * Create-only — changing it forces a replacement.
390 */
391 staging?: boolean;
392 /**
393 * Anycast static IP list ID to associate with the distribution.
394 */
395 anycastIpListId?: string;
396 /**
397 * User-defined tags to apply to the distribution.
398 */
399 tags?: Record<string, string>;
400}
401
402/**
403 * Binding contract of {@link Distribution}: composites contribute additional
404 * alternate domain names without a circular input prop (e.g. a site attached
405 * to an `AWS.Website.Router` binds its hostnames onto the Router's
406 * distribution). Bound aliases are merged with the declared `aliases` prop
407 * at reconcile time; the viewer certificate must cover them.
408 */
409export type DistributionBinding = {
410 /**
411 * Additional alternate domain names (CNAMEs) attached to the
412 * distribution.
413 */
414 aliases?: string[];
415};
416
417/**
418 * Union of declared and bound aliases (see {@link DistributionBinding}),
419 * deduped, preserving declared order first. Tolerates both `{ sid, data }`
420 * rows (provider lifecycle) and bare binding payloads.
421 * @internal
422 */
423const resolveEffectiveAliases = (
424 declared: string[] | undefined,
425 bindings:
426 | ReadonlyArray<DistributionBinding | ResourceBinding<DistributionBinding>>
427 | undefined,
428): string[] | undefined => {
429 const bound = (bindings ?? []).flatMap((binding) =>
430 "data" in binding && binding.data !== undefined
431 ? ((binding as ResourceBinding<DistributionBinding>).data.aliases ?? [])
432 : ((binding as DistributionBinding).aliases ?? []),
433 );
434 if (bound.length === 0) {
435 return declared;
436 }
437 return [...new Set([...(declared ?? []), ...bound])];
438};
439
440export interface Distribution extends Resource<
441 "AWS.CloudFront.Distribution",
442 DistributionProps,
443 {
444 /**
445 * CloudFront distribution identifier.
446 */
447 distributionId: string;
448 /**
449 * ARN of the distribution.
450 */
451 distributionArn: string;
452 /**
453 * CloudFront-assigned domain name.
454 */
455 domainName: string;
456 /**
457 * The distribution's own URL — what a viewer opens.
458 *
459 * `https://{domainName}` on AWS. Under `alchemy dev` the emulator has no
460 * such hostname to offer (`*.cloudfront.net` resolves to nothing on a
461 * developer's machine), so it serves the distribution's edge on a local
462 * port and this is `http://localhost:{port}`. Reading `url` instead of
463 * building one from {@link domainName} is what makes a consumer work
464 * unchanged in both modes.
465 */
466 url: string;
467 /**
468 * Route 53 hosted zone ID for CloudFront aliases.
469 */
470 hostedZoneId: string;
471 /**
472 * Current deployment status.
473 */
474 status: string;
475 /**
476 * Configured alternate domain names.
477 */
478 aliases: string[];
479 /**
480 * Current comment.
481 */
482 comment: string;
483 /**
484 * Whether the distribution is enabled.
485 */
486 enabled: boolean;
487 /**
488 * Most recent entity tag for update/delete operations.
489 */
490 etag: string | undefined;
491 /**
492 * Number of invalidation batches still in progress.
493 */
494 inProgressInvalidationBatches: number;
495 /**
496 * Last CloudFront modification timestamp.
497 */
498 lastModifiedTime: Date | undefined;
499 /**
500 * Current tags on the distribution.
501 */
502 tags: Record<string, string>;
503 },
504 DistributionBinding,
505 Providers
506> {}
507
508/**
509 * A CloudFront distribution.
510 *
511 * `Distribution` manages the CDN layer for static sites and HTTP origins such
512 * as Lambda Function URLs and ALBs. It exposes the distribution domain and
513 * hosted zone ID needed for Route 53 alias records.
514 * ### Creating Distributions
515 * **Example:** CDN in Front of an HTTP Origin
516 * ```typescript
517 * import * as AWS from "alchemy/AWS";
518 *
519 * const distribution = yield* AWS.CloudFront.Distribution("ApiCdn", {
520 * origins: [
521 * {
522 * id: "api",
523 * domainName: "abc123.lambda-url.us-west-2.on.aws",
524 * customOriginConfig: { originProtocolPolicy: "https-only" },
525 * },
526 * ],
527 * defaultCacheBehavior: {
528 * targetOriginId: "api",
529 * viewerProtocolPolicy: "redirect-to-https",
530 * cachePolicyId: AWS.CloudFront.MANAGED_CACHING_DISABLED_POLICY_ID,
531 * originRequestPolicyId:
532 * AWS.CloudFront.MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_POLICY_ID,
533 * },
534 * });
535 * ```
536 *
537 * **Example:** Private S3 Origin
538 * ```typescript
539 * const distribution = yield* Distribution("WebsiteCdn", {
540 * aliases: ["www.example.com"],
541 * origins: [
542 * {
543 * id: "site",
544 * domainName: bucket.bucketRegionalDomainName,
545 * s3Origin: true,
546 * originAccessControlId: oac.originAccessControlId,
547 * },
548 * ],
549 * defaultCacheBehavior: {
550 * targetOriginId: "site",
551 * viewerProtocolPolicy: "redirect-to-https",
552 * compress: true,
553 * },
554 * viewerCertificate: {
555 * acmCertificateArn: certificate.certificateArn,
556 * sslSupportMethod: "sni-only",
557 * minimumProtocolVersion: "TLSv1.2_2021",
558 * },
559 * });
560 * ```
561 *
562 * ### Invalidating the Cache
563 * **Example:** Purge Paths on Deploy
564 * ```typescript
565 * // declaratively, whenever `version` changes:
566 * yield* AWS.CloudFront.Invalidation("PurgeBlog", {
567 * distributionId: distribution.distributionId,
568 * paths: ["/blog/*"],
569 * version: buildId,
570 * });
571 * ```
572 *
573 * To purge at runtime from a Lambda Function, bind
574 * `CloudFront.CreateInvalidation(distribution)` instead.
575 *
576 * @resource
577 */
578export const Distribution = Resource<Distribution>(
579 "AWS.CloudFront.Distribution",
580);
581
582export const DistributionProvider = () =>
583 Provider.effect(
584 Distribution,
585 Effect.gen(function* () {
586 const waitForDeployment = Effect.fn(function* (distributionId: string) {
587 yield* Effect.logInfo(
588 `CloudFront Distribution wait: polling deployment for ${distributionId}`,
589 );
590 return yield* cloudfront.getDistribution({ Id: distributionId }).pipe(
591 // Bound each poll — a wedged read must count as "not deployed
592 // yet" and retry, never hang the deploy (see the delete-wait).
593 Effect.timeout(30_000),
594 Effect.catchTag("TimeoutError", () =>
595 Effect.fail(
596 new DistributionPendingDeployment({
597 message: `Timed out reading distribution ${distributionId} while polling deployment`,
598 }),
599 ),
600 ),
601 Effect.map((response) => response.Distribution),
602 Effect.flatMap((distribution) =>
603 distribution?.Status === "Deployed"
604 ? Effect.gen(function* () {
605 yield* Effect.logInfo(
606 `CloudFront Distribution wait: ${distributionId} deployed`,
607 );
608 return distribution;
609 })
610 : Effect.gen(function* () {
611 yield* Effect.logInfo(
612 `CloudFront Distribution wait: ${distributionId} status=${distribution?.Status ?? "unknown"}`,
613 );
614 return yield* Effect.fail(
615 new DistributionPendingDeployment({
616 message: `Distribution ${distributionId} is not yet deployed`,
617 }),
618 );
619 }),
620 ),
621 Effect.retry({
622 while: (error) => error._tag === "DistributionPendingDeployment",
623 schedule: Schedule.max([
624 Schedule.fixed("10 seconds"),
625 Schedule.recurs(60),
626 ]),
627 }),
628 );
629 });
630
631 const getCurrent = Effect.fn(function* (distributionId: string) {
632 yield* Effect.logInfo(
633 `CloudFront Distribution read: loading distribution ${distributionId}`,
634 );
635 const distribution = yield* cloudfront
636 .getDistribution({ Id: distributionId })
637 .pipe(
638 Effect.map((response) => response.Distribution),
639 Effect.catchTag("NoSuchDistribution", () =>
640 Effect.succeed(undefined),
641 ),
642 );
643
644 if (!distribution?.Id) {
645 yield* Effect.logInfo(
646 `CloudFront Distribution read: distribution ${distributionId} not found`,
647 );
648 return undefined;
649 }
650
651 yield* Effect.logInfo(
652 `CloudFront Distribution read: loading config and tags for ${distributionId}`,
653 );
654 const config = yield* cloudfront.getDistributionConfig({
655 Id: distributionId,
656 });
657 const tags = yield* cloudfront
658 .listTagsForResource({
659 Resource: distribution.ARN,
660 })
661 .pipe(Effect.map((response) => toTagsRecord(response.Tags.Items)));
662
663 yield* Effect.logInfo(
664 `CloudFront Distribution read: loaded ${distributionId} status=${distribution.Status} enabled=${config.DistributionConfig?.Enabled ?? "unknown"} etag=${config.ETag ?? "missing"} tags=${Object.keys(tags).length}`,
665 );
666 return {
667 distribution,
668 config: config.DistributionConfig!,
669 etag: config.ETag,
670 tags,
671 };
672 });
673
674 const getByCallerReference = Effect.fn(function* (
675 callerReference: string,
676 ) {
677 yield* Effect.logInfo(
678 `CloudFront Distribution read: searching for callerReference=${callerReference}`,
679 );
680 let marker: string | undefined;
681
682 do {
683 const listed = yield* cloudfront.listDistributions({
684 Marker: marker,
685 });
686
687 for (const item of listed.DistributionList?.Items ?? []) {
688 if (!item.Id) continue;
689
690 const config = yield* cloudfront
691 .getDistributionConfig({
692 Id: item.Id,
693 })
694 .pipe(
695 Effect.catchTag("NoSuchDistribution", () =>
696 Effect.succeed(undefined),
697 ),
698 );
699
700 if (
701 config?.DistributionConfig?.CallerReference === callerReference
702 ) {
703 yield* Effect.logInfo(
704 `CloudFront Distribution read: recovered ${item.Id} for callerReference=${callerReference}`,
705 );
706 return yield* getCurrent(item.Id);
707 }
708 }
709
710 marker = listed.DistributionList?.IsTruncated
711 ? listed.DistributionList.NextMarker
712 : undefined;
713 } while (marker);
714
715 yield* Effect.logInfo(
716 `CloudFront Distribution read: no distribution found for callerReference=${callerReference}`,
717 );
718 return undefined;
719 });
720
721 const waitForDeletionReady = Effect.fn(function* (
722 distributionId: string,
723 ) {
724 class DistributionPendingDeletionReadiness extends Data.TaggedError(
725 "DistributionPendingDeletionReadiness",
726 )<{
727 message: string;
728 }> {}
729
730 yield* Effect.logInfo(
731 `CloudFront Distribution delete: waiting for ${distributionId} to become disabled and deployed`,
732 );
733 return yield* Effect.logInfo(
734 `CloudFront Distribution delete: waiting for ${distributionId} to become disabled and deployed`,
735 ).pipe(
736 // Bound each poll: a single wedged HTTP read (stale keep-alive
737 // socket) otherwise hangs the whole destroy — observed as a
738 // disable-wait that logged one poll and then sat silent past a
739 // 60-minute test budget. Timeout counts as "not ready yet" and
740 // rides the same bounded retry.
741 Effect.andThen(() =>
742 getCurrent(distributionId).pipe(
743 Effect.timeout(30_000),
744 Effect.catchTag("TimeoutError", () =>
745 Effect.fail(
746 new DistributionPendingDeletionReadiness({
747 message: `Timed out reading distribution ${distributionId} while waiting for deletion readiness`,
748 }),
749 ),
750 ),
751 ),
752 ),
753 Effect.flatMap(
754 Effect.fn(function* (current) {
755 if (!current) {
756 yield* Effect.logInfo(
757 `CloudFront Distribution delete: ${distributionId} already absent while waiting`,
758 );
759 return undefined;
760 }
761
762 if (
763 current.config.Enabled ||
764 current.distribution.Status !== "Deployed"
765 ) {
766 yield* Effect.logInfo(
767 `CloudFront Distribution delete: ${distributionId} not ready enabled=${current.config.Enabled} status=${current.distribution.Status}`,
768 );
769 return yield* Effect.fail(
770 new DistributionPendingDeletionReadiness({
771 message: `Distribution ${distributionId} is not yet ready for deletion`,
772 }),
773 );
774 }
775
776 yield* Effect.logInfo(
777 `CloudFront Distribution delete: ${distributionId} ready for delete with etag=${current.etag ?? "missing"}`,
778 );
779 return current;
780 }),
781 ),
782 Effect.retry({
783 while: (error) =>
784 error._tag === "DistributionPendingDeletionReadiness",
785 schedule: Schedule.max([
786 Schedule.fixed("10 seconds"),
787 Schedule.recurs(60),
788 ]),
789 }),
790 );
791 });
792
793 return {
794 stables: [
795 "distributionId",
796 "distributionArn",
797 "domainName",
798 "hostedZoneId",
799 ],
800 // `Staging` is create-only at the CloudFront API level; toggling it
801 // requires a fresh distribution. Everything else updates in place via
802 // the whole-config `updateDistribution` PUT.
803 diff: Effect.fn(function* ({ olds, news: _news }) {
804 if (!isResolved(_news)) return undefined;
805 const news = _news as DistributionProps;
806 if ((olds?.staging ?? false) !== (news.staging ?? false)) {
807 return { action: "replace" } as const;
808 }
809 }),
810 read: Effect.fn(function* ({ output }) {
811 if (!output?.distributionId) {
812 return undefined;
813 }
814
815 const current = yield* getCurrent(output.distributionId);
816 if (!current) {
817 return undefined;
818 }
819
820 return toAttrs(
821 current.distribution,
822 current.etag,
823 current.tags,
824 yield* resolveUrl(current.distribution),
825 );
826 }),
827 // CloudFront is a global service (no region). Enumerate every
828 // distribution in the account via the paginated `listDistributions`
829 // op, then resolve each summary to the full `read`-shaped Attributes
830 // (distribution + config + tags) so callers get a `delete`-ready item.
831 list: () =>
832 Effect.gen(function* () {
833 const summaries = yield* cloudfront.listDistributions
834 .pages({})
835 .pipe(
836 Stream.runCollect,
837 Effect.map((chunk) =>
838 Array.from(chunk).flatMap(
839 (page) => page.DistributionList?.Items ?? [],
840 ),
841 ),
842 );
843
844 const rows = yield* Effect.forEach(
845 summaries,
846 (summary) =>
847 getCurrent(summary.Id).pipe(
848 // Hydrating every distribution fires three read calls each
849 // (getDistribution + getDistributionConfig +
850 // listTagsForResource). Across a busy account that trips
851 // CloudFront's low read throttle, so ride it out.
852 Effect.retry({
853 while: (error) => error._tag === "ThrottlingException",
854 // Steady, capped backoff: CloudFront's read throttle
855 // resets quickly, so a fixed cadence drains far faster
856 // than an exponential whose tail delay can balloon past
857 // the test budget on a busy account.
858 schedule: Schedule.max([
859 Schedule.spaced("1 second").pipe(Schedule.jittered),
860 Schedule.recurs(30),
861 ]),
862 }),
863 Effect.flatMap((current) =>
864 current
865 ? Effect.map(
866 resolveUrl(current.distribution),
867 (url) =>
868 toAttrs(
869 current.distribution,
870 current.etag,
871 current.tags,
872 url,
873 ) as Distribution["Attributes"] | undefined,
874 )
875 : Effect.succeed(undefined),
876 ),
877 ),
878 { concurrency: 10 },
879 );
880
881 return rows.filter(
882 (row): row is Distribution["Attributes"] => row !== undefined,
883 );
884 }),
885 reconcile: Effect.fn(function* ({
886 id,
887 instanceId,
888 news: _news,
889 output,
890 session,
891 bindings,
892 }) {
893 // Fold bound aliases (see `DistributionBinding`) into the desired
894 // props up front so both the create and update paths (`toConfig`)
895 // attach them uniformly.
896 const news: typeof _news = {
897 ..._news,
898 aliases: resolveEffectiveAliases(_news.aliases, bindings),
899 };
900 const desiredTags = {
901 ...(yield* createInternalTags(id)),
902 ...news.tags,
903 };
904 const callerReference = instanceId;
905
906 // Observe — locate an existing distribution by id (cached on
907 // `output`) or by caller reference, which lets us recover from a
908 // create that succeeded in the cloud but failed to persist its
909 // attributes locally.
910 let observed = output?.distributionId
911 ? yield* getCurrent(output.distributionId)
912 : undefined;
913 if (!observed) {
914 observed = yield* getByCallerReference(callerReference);
915 }
916
917 // Ensure — create the distribution if it's missing. Tolerate
918 // `DistributionAlreadyExists` (race with a peer reconciler) by
919 // re-reading via caller reference. Tags are applied at create
920 // time when permissions allow; otherwise we fall back to
921 // create-then-tag and the sync path below converges them.
922 if (!observed) {
923 const config = toConfig(callerReference, news);
924 yield* Effect.logInfo(
925 `CloudFront Distribution reconcile: callerReference=${callerReference} aliases=${news.aliases?.length ?? 0} origins=${(news.origins as DistributionOrigin[]).length} tags=${Object.keys(desiredTags).length}`,
926 );
927 const created = yield* cloudfront
928 .createDistributionWithTags({
929 DistributionConfigWithTags: {
930 DistributionConfig: config,
931 Tags: {
932 Items: createTagsList(desiredTags),
933 },
934 },
935 })
936 .pipe(
937 Effect.catch((error) =>
938 isAccessDenied(error)
939 ? Effect.gen(function* () {
940 yield* Effect.logInfo(
941 `CloudFront Distribution reconcile: createDistributionWithTags denied, retrying without tags for callerReference=${callerReference}`,
942 );
943 const created = yield* cloudfront.createDistribution({
944 DistributionConfig: config,
945 });
946
947 if (
948 created.Distribution?.ARN &&
949 Object.keys(desiredTags).length > 0
950 ) {
951 yield* Effect.logInfo(
952 `CloudFront Distribution reconcile: tagging distribution ${created.Distribution.Id} after fallback`,
953 );
954 yield* cloudfront.tagResource({
955 Resource: created.Distribution.ARN,
956 Tags: {
957 Items: createTagsList(desiredTags),
958 },
959 });
960 }
961
962 return created;
963 })
964 : Effect.gen(function* () {
965 yield* Effect.logInfo(
966 `CloudFront Distribution reconcile: createDistributionWithTags failed for callerReference=${callerReference} error=${String(error)}`,
967 );
968 return yield* Effect.fail(error);
969 }),
970 ),
971 )
972 .pipe(
973 Effect.map((created) => ({
974 distributionId: created.Distribution?.Id,
975 etag: created.ETag,
976 tags: desiredTags,
977 })),
978 Effect.catchTag("DistributionAlreadyExists", () =>
979 Effect.gen(function* () {
980 yield* Effect.logInfo(
981 `CloudFront Distribution reconcile: callerReference=${callerReference} already exists, attempting recovery`,
982 );
983 const recovered =
984 yield* getByCallerReference(callerReference);
985 if (!recovered?.distribution.Id) {
986 return yield* Effect.fail(
987 new Error(
988 `CloudFront distribution with caller reference '${callerReference}' already exists but could not be recovered`,
989 ),
990 );
991 }
992 return {
993 distributionId: recovered.distribution.Id,
994 etag: recovered.etag,
995 tags: recovered.tags,
996 };
997 }),
998 ),
999 Effect.catchTag(
1000 "InvalidArgument",
1001 (
1002 error,
1003 ): Effect.Effect<
1004 never,
1005 | cloudfront.InvalidArgument
1006 | DistributionFunctionAssociationPending
1007 > =>
1008 isFunctionAssociationPending(error)
1009 ? Effect.logInfo(
1010 "CloudFront Distribution reconcile: function association not yet ready, retrying",
1011 ).pipe(
1012 Effect.andThen(
1013 Effect.fail(
1014 new DistributionFunctionAssociationPending({
1015 message:
1016 error.message ??
1017 "CloudFront function association pending",
1018 }),
1019 ),
1020 ),
1021 )
1022 : Effect.fail(error),
1023 ),
1024 Effect.retry({
1025 while: (error) =>
1026 error instanceof DistributionFunctionAssociationPending,
1027 schedule: Schedule.max([
1028 Schedule.fixed("5 seconds"),
1029 Schedule.recurs(24),
1030 ]),
1031 }),
1032 );
1033
1034 if (!created.distributionId) {
1035 return yield* Effect.fail(
1036 new Error("createDistribution returned no distribution"),
1037 );
1038 }
1039
1040 yield* Effect.logInfo(
1041 `CloudFront Distribution reconcile: created ${created.distributionId} etag=${created.etag ?? "missing"}, waiting for deployment`,
1042 );
1043 const deployed = yield* waitForDeployment(created.distributionId);
1044 yield* Effect.logInfo(
1045 `CloudFront Distribution reconcile: deployed ${created.distributionId} domain=${deployed.DomainName}`,
1046 );
1047 yield* session.note(created.distributionId);
1048 return toAttrs(
1049 deployed,
1050 created.etag,
1051 created.tags,
1052 yield* resolveUrl(deployed),
1053 );
1054 }
1055
1056 // Sync config — diff observed config against desired and patch
1057 // via `updateDistribution` with the freshly observed ETag. We
1058 // keep the observed `CallerReference` because CloudFront does
1059 // not allow it to change, and fill every member the props don't
1060 // express from the observed config: UpdateDistribution replaces
1061 // the whole config and rejects requests with missing members
1062 // (see `mergeWithObservedConfig`).
1063 yield* Effect.logInfo(
1064 `CloudFront Distribution reconcile: updating config for ${observed.distribution.Id} with etag=${observed.etag ?? "missing"}`,
1065 );
1066 const updated = yield* cloudfront
1067 .updateDistribution({
1068 Id: observed.distribution.Id,
1069 IfMatch: observed.etag,
1070 DistributionConfig: mergeWithObservedConfig(
1071 toConfig(observed.config.CallerReference, news),
1072 observed.config,
1073 ),
1074 })
1075 .pipe(
1076 Effect.catchTag(
1077 "InvalidArgument",
1078 (
1079 error,
1080 ): Effect.Effect<
1081 never,
1082 | cloudfront.InvalidArgument
1083 | DistributionFunctionAssociationPending
1084 > =>
1085 isFunctionAssociationPending(error)
1086 ? Effect.logInfo(
1087 "CloudFront Distribution reconcile: function association not yet ready, retrying",
1088 ).pipe(
1089 Effect.andThen(
1090 Effect.fail(
1091 new DistributionFunctionAssociationPending({
1092 message:
1093 error.message ??
1094 "CloudFront function association pending",
1095 }),
1096 ),
1097 ),
1098 )
1099 : Effect.fail(error),
1100 ),
1101 Effect.retry({
1102 while: (error) =>
1103 error instanceof DistributionFunctionAssociationPending,
1104 schedule: Schedule.max([
1105 Schedule.fixed("5 seconds"),
1106 Schedule.recurs(24),
1107 ]),
1108 }),
1109 );
1110
1111 if (!updated.Distribution?.Id) {
1112 return yield* Effect.fail(
1113 new Error("updateDistribution returned no distribution"),
1114 );
1115 }
1116
1117 // Sync tags — diff observed cloud tags against desired and apply
1118 // only the delta. `observed.tags` is fetched fresh, so we don't
1119 // rely on stale `olds.tags`.
1120 const { removed, upsert } = diffTags(observed.tags, desiredTags);
1121 yield* Effect.logInfo(
1122 `CloudFront Distribution reconcile: distribution=${observed.distribution.Id} upsertTags=${upsert.length} removedTags=${removed.length}`,
1123 );
1124
1125 if (upsert.length > 0) {
1126 yield* Effect.logInfo(
1127 `CloudFront Distribution reconcile: tagging ${observed.distribution.Id} with ${upsert.length} tag(s)`,
1128 );
1129 yield* cloudfront.tagResource({
1130 Resource: observed.distribution.ARN,
1131 Tags: {
1132 Items: upsert,
1133 },
1134 });
1135 }
1136
1137 if (removed.length > 0) {
1138 yield* Effect.logInfo(
1139 `CloudFront Distribution reconcile: removing ${removed.length} tag(s) from ${observed.distribution.Id}`,
1140 );
1141 yield* cloudfront.untagResource({
1142 Resource: observed.distribution.ARN,
1143 TagKeys: {
1144 Items: removed,
1145 },
1146 });
1147 }
1148
1149 yield* Effect.logInfo(
1150 `CloudFront Distribution reconcile: updated ${observed.distribution.Id} etag=${updated.ETag ?? "missing"}, waiting for deployment`,
1151 );
1152 const deployed = yield* waitForDeployment(updated.Distribution.Id);
1153 yield* Effect.logInfo(
1154 `CloudFront Distribution reconcile: deployed ${observed.distribution.Id} domain=${deployed.DomainName}`,
1155 );
1156 yield* session.note(observed.distribution.Id);
1157 return toAttrs(
1158 deployed,
1159 updated.ETag,
1160 desiredTags,
1161 yield* resolveUrl(deployed),
1162 );
1163 }),
1164 delete: Effect.fn(function* ({ output }) {
1165 yield* Effect.logInfo(
1166 `CloudFront Distribution delete: distribution=${output.distributionId}`,
1167 );
1168 const current = yield* getCurrent(output.distributionId);
1169 if (!current) {
1170 yield* Effect.logInfo(
1171 `CloudFront Distribution delete: ${output.distributionId} already absent`,
1172 );
1173 return;
1174 }
1175
1176 if (current.config.Enabled) {
1177 yield* Effect.logInfo(
1178 `CloudFront Distribution delete: disabling ${output.distributionId} before delete`,
1179 );
1180 yield* cloudfront.updateDistribution({
1181 Id: output.distributionId,
1182 IfMatch: current.etag,
1183 DistributionConfig: {
1184 ...current.config,
1185 Enabled: false,
1186 },
1187 });
1188 }
1189
1190 const latest = yield* waitForDeletionReady(output.distributionId);
1191 if (!latest) {
1192 yield* Effect.logInfo(
1193 `CloudFront Distribution delete: ${output.distributionId} disappeared before delete`,
1194 );
1195 return;
1196 }
1197
1198 yield* Effect.logInfo(
1199 `CloudFront Distribution delete: deleting ${output.distributionId} with etag=${latest.etag ?? "missing"}`,
1200 );
1201 yield* cloudfront
1202 .deleteDistribution({
1203 Id: output.distributionId,
1204 IfMatch: latest.etag,
1205 })
1206 .pipe(Effect.catchTag("NoSuchDistribution", () => Effect.void));
1207 }),
1208 };
1209 }),
1210 );
1211
1212const toTagsRecord = (tags: cloudfront.Tag[] | undefined) =>
1213 Object.fromEntries(
1214 (tags ?? [])
1215 .filter(
1216 (tag): tag is { Key: string; Value: string } =>
1217 typeof tag.Key === "string" && typeof tag.Value === "string",
1218 )
1219 .map((tag) => [tag.Key, tag.Value]),
1220 );
1221
1222const isAccessDenied = (error: unknown) => {
1223 const tag = (error as { _tag?: string; name?: string })?._tag;
1224 const name = (error as { _tag?: string; name?: string })?.name;
1225 const text = String(error);
1226 return (
1227 tag === "AccessDenied" ||
1228 tag === "AccessDeniedException" ||
1229 name === "AccessDenied" ||
1230 name === "AccessDeniedException" ||
1231 text.includes("AccessDenied")
1232 );
1233};
1234
1235const toBehavior = (
1236 behavior: DistributionBehavior & {
1237 pathPattern?: string;
1238 },
1239): cloudfront.CacheBehavior | cloudfront.DefaultCacheBehavior => ({
1240 ...(behavior.pathPattern ? { PathPattern: behavior.pathPattern } : undefined),
1241 TargetOriginId: behavior.targetOriginId,
1242 ViewerProtocolPolicy: behavior.viewerProtocolPolicy ?? "redirect-to-https",
1243 AllowedMethods: behavior.allowedMethods
1244 ? {
1245 Quantity: behavior.allowedMethods.length,
1246 Items: behavior.allowedMethods,
1247 CachedMethods: behavior.cachedMethods
1248 ? {
1249 Quantity: behavior.cachedMethods.length,
1250 Items: behavior.cachedMethods,
1251 }
1252 : undefined,
1253 }
1254 : undefined,
1255 Compress: behavior.compress ?? true,
1256 CachePolicyId: behavior.cachePolicyId,
1257 OriginRequestPolicyId: behavior.originRequestPolicyId,
1258 ResponseHeadersPolicyId: behavior.responseHeadersPolicyId,
1259 // Without a cache policy, CloudFront is in legacy mode and rejects the
1260 // request unless both ForwardedValues and MinTTL are present.
1261 ForwardedValues:
1262 behavior.forwardedValues ??
1263 (behavior.cachePolicyId === undefined
1264 ? { QueryString: false, Cookies: { Forward: "none" } }
1265 : undefined),
1266 MinTTL:
1267 toWireSeconds(behavior.minTtl) ??
1268 (behavior.cachePolicyId === undefined ? 0 : undefined),
1269 DefaultTTL: toWireSeconds(behavior.defaultTtl),
1270 MaxTTL: toWireSeconds(behavior.maxTtl),
1271 TrustedKeyGroups: behavior.trustedKeyGroups
1272 ? {
1273 Enabled: (behavior.trustedKeyGroups as string[]).length > 0,
1274 Quantity: (behavior.trustedKeyGroups as string[]).length,
1275 Items: behavior.trustedKeyGroups as string[],
1276 }
1277 : undefined,
1278 TrustedSigners: behavior.trustedSigners
1279 ? {
1280 Enabled: behavior.trustedSigners.length > 0,
1281 Quantity: behavior.trustedSigners.length,
1282 Items: behavior.trustedSigners,
1283 }
1284 : undefined,
1285 FieldLevelEncryptionId: behavior.fieldLevelEncryptionId,
1286 RealtimeLogConfigArn: behavior.realtimeLogConfigArn,
1287 SmoothStreaming: behavior.smoothStreaming,
1288 GrpcConfig: behavior.grpcConfig
1289 ? { Enabled: behavior.grpcConfig.enabled }
1290 : undefined,
1291 FunctionAssociations: behavior.functionAssociations
1292 ? {
1293 Quantity: behavior.functionAssociations.length,
1294 Items: behavior.functionAssociations.map((association) => ({
1295 FunctionARN: association.functionArn,
1296 EventType: association.eventType,
1297 })),
1298 }
1299 : undefined,
1300 LambdaFunctionAssociations: behavior.lambdaFunctionAssociations
1301 ? {
1302 Quantity: behavior.lambdaFunctionAssociations.length,
1303 Items: behavior.lambdaFunctionAssociations.map((association) => ({
1304 LambdaFunctionARN: association.lambdaFunctionArn,
1305 EventType: association.eventType,
1306 IncludeBody: association.includeBody,
1307 })),
1308 }
1309 : undefined,
1310});
1311
1312const toOrigin = (origin: DistributionOrigin): cloudfront.Origin => {
1313 // Exactly one of the three origin-config shapes may be set. A VPC origin
1314 // wins, then an explicit/legacy S3 origin, otherwise a custom origin.
1315 const isVpcOrigin = origin.vpcOriginConfig !== undefined;
1316 const isS3Origin =
1317 !isVpcOrigin &&
1318 (origin.s3Origin === true || origin.s3OriginConfig !== undefined);
1319
1320 return {
1321 Id: origin.id,
1322 DomainName: origin.domainName as string,
1323 OriginPath: origin.originPath as string | undefined,
1324 OriginAccessControlId: origin.originAccessControlId as string | undefined,
1325 CustomHeaders: origin.customHeaders
1326 ? {
1327 Quantity: Object.keys(origin.customHeaders).length,
1328 Items: Object.entries(origin.customHeaders).map(([name, value]) => ({
1329 HeaderName: name,
1330 HeaderValue: value,
1331 })),
1332 }
1333 : undefined,
1334 OriginShield: origin.originShield
1335 ? {
1336 Enabled: origin.originShield.enabled,
1337 OriginShieldRegion: origin.originShield.originShieldRegion,
1338 }
1339 : undefined,
1340 ConnectionAttempts: origin.connectionAttempts,
1341 ConnectionTimeout: toWireSeconds(origin.connectionTimeout),
1342 ResponseCompletionTimeout: toWireSeconds(origin.responseCompletionTimeout),
1343 VpcOriginConfig: isVpcOrigin
1344 ? {
1345 VpcOriginId: origin.vpcOriginConfig!.vpcOriginId as string,
1346 OwnerAccountId: origin.vpcOriginConfig!.ownerAccountId,
1347 OriginReadTimeout: toWireSeconds(
1348 origin.vpcOriginConfig!.originReadTimeout,
1349 ),
1350 OriginKeepaliveTimeout: toWireSeconds(
1351 origin.vpcOriginConfig!.originKeepaliveTimeout,
1352 ),
1353 }
1354 : undefined,
1355 S3OriginConfig: isS3Origin
1356 ? {
1357 OriginAccessIdentity:
1358 origin.s3OriginConfig?.originAccessIdentity ?? "",
1359 OriginReadTimeout: toWireSeconds(
1360 origin.s3OriginConfig?.originReadTimeout,
1361 ),
1362 }
1363 : undefined,
1364 CustomOriginConfig:
1365 isVpcOrigin || isS3Origin
1366 ? undefined
1367 : {
1368 HTTPPort: origin.customOriginConfig?.httpPort ?? 80,
1369 HTTPSPort: origin.customOriginConfig?.httpsPort ?? 443,
1370 OriginProtocolPolicy:
1371 origin.customOriginConfig?.originProtocolPolicy ?? "https-only",
1372 OriginSslProtocols: {
1373 Quantity: (
1374 origin.customOriginConfig?.originSslProtocols ?? ["TLSv1.2"]
1375 ).length,
1376 Items: origin.customOriginConfig?.originSslProtocols ?? [
1377 "TLSv1.2",
1378 ],
1379 },
1380 OriginReadTimeout: toWireSeconds(
1381 origin.customOriginConfig?.originReadTimeout,
1382 ),
1383 OriginKeepaliveTimeout: toWireSeconds(
1384 origin.customOriginConfig?.originKeepaliveTimeout,
1385 ),
1386 IpAddressType: origin.customOriginConfig?.ipAddressType,
1387 OriginMtlsConfig: origin.customOriginConfig?.originMtlsConfig
1388 ? {
1389 ClientCertificateArn:
1390 origin.customOriginConfig.originMtlsConfig
1391 .clientCertificateArn,
1392 }
1393 : undefined,
1394 },
1395 };
1396};
1397
1398/**
1399 * Recursively fills `undefined` members of `desired` from `observed`.
1400 * Defined values in `desired` always win; arrays are taken from `desired`
1401 * wholesale (item-level merging is the caller's concern).
1402 */
1403const fillUndefined = <T>(desired: T, observed: T): T => {
1404 if (desired === undefined) return observed;
1405 if (
1406 desired === null ||
1407 typeof desired !== "object" ||
1408 Array.isArray(desired) ||
1409 observed === null ||
1410 typeof observed !== "object" ||
1411 Array.isArray(observed)
1412 ) {
1413 return desired;
1414 }
1415 const out: Record<string, unknown> = {
1416 ...(desired as Record<string, unknown>),
1417 };
1418 const desiredObj = desired as Record<string, unknown>;
1419 for (const key of Object.keys(observed as Record<string, unknown>)) {
1420 // CloudFront list shapes pair `Quantity` with `Items`. A desired node
1421 // that declares a `Quantity` but no `Items` (e.g. a geo restriction of
1422 // `{ RestrictionType: "none", Quantity: 0 }`) is fully specified —
1423 // filling `Items` from the observed config would desynchronize the two
1424 // and CloudFront rejects the update with `InconsistentQuantities`.
1425 if (
1426 key === "Items" &&
1427 typeof desiredObj.Quantity === "number" &&
1428 desiredObj.Items === undefined
1429 ) {
1430 continue;
1431 }
1432 out[key] = fillUndefined(
1433 desiredObj[key],
1434 (observed as Record<string, unknown>)[key],
1435 );
1436 }
1437 return out as T;
1438};
1439
1440/**
1441 * Completes a desired `DistributionConfig` with the freshly observed live
1442 * config before an `updateDistribution` call.
1443 *
1444 * CloudFront's `UpdateDistribution` replaces the ENTIRE distribution config:
1445 * any member missing from the request is rejected with `IllegalUpdate`
1446 * (observed in practice: "Default root object is missing for the resource",
1447 * "The 'OriginCustomHeaders' field is missing"). `toConfig` emits only the
1448 * members the props express, so updating a live distribution fails on every
1449 * member the props don't model — some of which (e.g. per-origin
1450 * `CustomHeaders`, per-behavior `TrustedSigners`) are nested inside
1451 * collections and not expressible as props at all.
1452 *
1453 * The standard CloudFront update pattern is read-modify-write, which the
1454 * delete path here already uses (`{ ...current.config, Enabled: false }`).
1455 * This applies the same idea to updates: desired values always win — the
1456 * declared props still fully control drift — and only `undefined` members
1457 * are carried over from the observed config. `Origins` and `CacheBehaviors`
1458 * items are additionally merged by identity (`Id` / `PathPattern`) so their
1459 * nested unexpressed members carry over too.
1460 *
1461 * Exported for unit tests.
1462 */
1463export const mergeWithObservedConfig = (
1464 desired: cloudfront.DistributionConfig,
1465 observed: cloudfront.DistributionConfig,
1466): cloudfront.DistributionConfig => {
1467 const merged = fillUndefined(desired, observed);
1468 if (merged.Origins?.Items && observed.Origins?.Items) {
1469 const observedOrigins = observed.Origins.Items;
1470 merged.Origins = {
1471 ...merged.Origins,
1472 Items: merged.Origins.Items.map((item) =>
1473 fillUndefined(
1474 item,
1475 observedOrigins.find((origin) => origin.Id === item.Id) ?? item,
1476 ),
1477 ),
1478 };
1479 }
1480 if (merged.CacheBehaviors?.Items && observed.CacheBehaviors?.Items) {
1481 const observedBehaviors = observed.CacheBehaviors.Items;
1482 merged.CacheBehaviors = {
1483 ...merged.CacheBehaviors,
1484 Items: merged.CacheBehaviors.Items.map((item) =>
1485 fillUndefined(
1486 item,
1487 observedBehaviors.find(
1488 (behavior) => behavior.PathPattern === item.PathPattern,
1489 ) ?? item,
1490 ),
1491 ),
1492 };
1493 }
1494 return merged;
1495};
1496
1497const toConfig = (
1498 callerReference: string,
1499 props: DistributionProps,
1500): cloudfront.DistributionConfig => ({
1501 CallerReference: callerReference,
1502 Aliases: props.aliases
1503 ? {
1504 Quantity: props.aliases.length,
1505 Items: props.aliases,
1506 }
1507 : undefined,
1508 DefaultRootObject: props.defaultRootObject,
1509 Origins: {
1510 Quantity: (props.origins as DistributionOrigin[]).length,
1511 Items: (props.origins as DistributionOrigin[]).map(toOrigin),
1512 },
1513 DefaultCacheBehavior: toBehavior(
1514 props.defaultCacheBehavior as DistributionBehavior,
1515 ) as cloudfront.DefaultCacheBehavior,
1516 CacheBehaviors: props.orderedCacheBehaviors
1517 ? {
1518 Quantity: (
1519 props.orderedCacheBehaviors as Array<
1520 DistributionBehavior & { pathPattern: string }
1521 >
1522 ).length,
1523 Items: (
1524 props.orderedCacheBehaviors as Array<
1525 DistributionBehavior & { pathPattern: string }
1526 >
1527 ).map((behavior) =>
1528 toBehavior(
1529 behavior as DistributionBehavior & { pathPattern: string },
1530 ),
1531 ) as cloudfront.CacheBehavior[],
1532 }
1533 : undefined,
1534 OriginGroups: props.originGroups
1535 ? {
1536 Quantity: (props.originGroups as DistributionOriginGroup[]).length,
1537 Items: (props.originGroups as DistributionOriginGroup[]).map(
1538 (group) => ({
1539 Id: group.id,
1540 FailoverCriteria: {
1541 StatusCodes: {
1542 Quantity: group.failoverStatusCodes.length,
1543 Items: group.failoverStatusCodes,
1544 },
1545 },
1546 Members: {
1547 Quantity: group.members.length,
1548 Items: group.members.map((originId) => ({ OriginId: originId })),
1549 },
1550 SelectionCriteria: group.selectionCriteria,
1551 }),
1552 ),
1553 }
1554 : undefined,
1555 CustomErrorResponses: props.customErrorResponses
1556 ? {
1557 Quantity: (
1558 props.customErrorResponses as cloudfront.CustomErrorResponse[]
1559 ).length,
1560 Items: props.customErrorResponses as cloudfront.CustomErrorResponse[],
1561 }
1562 : undefined,
1563 Comment: props.comment ?? "",
1564 Logging: props.logging
1565 ? {
1566 Enabled: props.logging.enabled ?? true,
1567 IncludeCookies: props.logging.includeCookies ?? false,
1568 Bucket: props.logging.bucket ?? "",
1569 Prefix: props.logging.prefix ?? "",
1570 }
1571 : undefined,
1572 Enabled: props.enabled ?? true,
1573 ViewerCertificate: props.viewerCertificate
1574 ? {
1575 CloudFrontDefaultCertificate: (
1576 props.viewerCertificate as DistributionViewerCertificate
1577 ).cloudFrontDefaultCertificate,
1578 IAMCertificateId: (
1579 props.viewerCertificate as DistributionViewerCertificate
1580 ).iamCertificateId,
1581 ACMCertificateArn: (
1582 props.viewerCertificate as DistributionViewerCertificate
1583 ).acmCertificateArn,
1584 SSLSupportMethod: (
1585 props.viewerCertificate as DistributionViewerCertificate
1586 ).sslSupportMethod,
1587 MinimumProtocolVersion: (
1588 props.viewerCertificate as DistributionViewerCertificate
1589 ).minimumProtocolVersion,
1590 Certificate: (props.viewerCertificate as DistributionViewerCertificate)
1591 .certificate,
1592 CertificateSource: (
1593 props.viewerCertificate as DistributionViewerCertificate
1594 ).certificateSource,
1595 }
1596 : props.aliases && props.aliases.length > 0
1597 ? undefined
1598 : {
1599 CloudFrontDefaultCertificate: true,
1600 },
1601 Restrictions: props.geoRestriction
1602 ? {
1603 GeoRestriction: {
1604 RestrictionType: props.geoRestriction.restrictionType,
1605 Quantity: props.geoRestriction.locations?.length ?? 0,
1606 Items: props.geoRestriction.locations,
1607 },
1608 }
1609 : {
1610 GeoRestriction: {
1611 RestrictionType: "none",
1612 Quantity: 0,
1613 },
1614 },
1615 PriceClass: props.priceClass,
1616 WebACLId: props.webAclId,
1617 HttpVersion: props.httpVersion ?? "http2",
1618 IsIPV6Enabled: props.isIpv6Enabled ?? true,
1619 ContinuousDeploymentPolicyId: props.continuousDeploymentPolicyId,
1620 Staging: props.staging,
1621 AnycastIpListId: props.anycastIpListId,
1622});
1623
1624/**
1625 * The distribution's own URL.
1626 *
1627 * On AWS this is `https://` + the CloudFront domain name. The local emulator
1628 * has no such hostname — `*.cloudfront.net` resolves to nothing on a
1629 * developer's machine — so it serves each distribution's edge on a plain-HTTP
1630 * port of its own and reports which one. Asking it here (rather than having
1631 * every consumer interpolate a URL from `domainName`) is what keeps `url`
1632 * openable in both modes. Anything unexpected falls back to the AWS-shaped
1633 * URL: a missing edge port must never fail a reconcile.
1634 */
1635const resolveUrl = Effect.fn("AWS.CloudFront.Distribution.url")(function* (
1636 distribution: cloudfront.Distribution,
1637) {
1638 const awsUrl = `https://${distribution.DomainName}`;
1639 const env = yield* AWSEnvironment.current;
1640 const endpoint = env.endpoint;
1641 if (endpoint === undefined || !(yield* AWSEnvironment.isLocalEmulator)) {
1642 return awsUrl;
1643 }
1644 return yield* Effect.gen(function* () {
1645 const client = yield* HttpClient.HttpClient;
1646 const response = yield* client.get(
1647 `${endpoint}/_floci/cloudfront-edge/${distribution.Id}`,
1648 );
1649 // A distribution legitimately has no edge port: the emulator binds one
1650 // opportunistically and stays Host-addressable when it can't, and the
1651 // endpoint 404s (with a JSON error body) for one it doesn't know yet.
1652 // `client.get` does not fail on 404, so every shape lands here — `null`,
1653 // an error object, or a port-less entry. Anything that isn't a numeric
1654 // `Port` means "no local edge", which is the AWS URL.
1655 const edge = (yield* response.json) as { Port?: number } | null;
1656 if (
1657 edge === null ||
1658 typeof edge !== "object" ||
1659 typeof edge.Port !== "number"
1660 ) {
1661 return awsUrl;
1662 }
1663 const host = yield* Effect.sync(() => new URL(endpoint).hostname);
1664 return `http://${host}:${edge.Port}`;
1665 }).pipe(
1666 Effect.timeout("10 seconds"),
1667 Effect.provide(FetchHttpClient.layer),
1668 // `orElseSucceed` alone only covers the error channel. A malformed body
1669 // throws inside the generator, which Effect surfaces as a DEFECT — that
1670 // escaped the fallback and killed a reconcile with a raw TypeError.
1671 // Resolving a convenience URL must never be able to fail a deploy.
1672 Effect.catchCause(() => Effect.succeed(awsUrl)),
1673 );
1674});
1675
1676const toAttrs = (
1677 distribution: cloudfront.Distribution,
1678 etag: string | undefined,
1679 tags: Record<string, string>,
1680 url: string,
1681): Distribution["Attributes"] => ({
1682 distributionId: distribution.Id,
1683 distributionArn: distribution.ARN,
1684 domainName: distribution.DomainName,
1685 url,
1686 hostedZoneId: CLOUDFRONT_HOSTED_ZONE_ID,
1687 status: distribution.Status,
1688 aliases: distribution.DistributionConfig.Aliases?.Items ?? [],
1689 comment:
1690 typeof distribution.DistributionConfig.Comment === "string"
1691 ? distribution.DistributionConfig.Comment
1692 : "",
1693 enabled: distribution.DistributionConfig.Enabled,
1694 etag,
1695 inProgressInvalidationBatches: distribution.InProgressInvalidationBatches,
1696 lastModifiedTime: distribution.LastModifiedTime,
1697 tags,
1698});