1import * as cloudfront from "@distilled.cloud/aws/cloudfront";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9
10export interface InvalidationProps {
11 /**
12 * Distribution to invalidate.
13 */
14 distributionId: string;
15 /**
16 * Version string used as the invalidation caller reference. Change this value
17 * to trigger a new invalidation.
18 */
19 version: string;
20 /**
21 * Paths to invalidate.
22 * @default ["/*"]
23 */
24 paths?: string[];
25 /**
26 * Wait for the invalidation to complete.
27 * @default false
28 */
29 wait?: boolean;
30}
31
32export interface Invalidation extends Resource<
33 "AWS.CloudFront.Invalidation",
34 InvalidationProps,
35 {
36 /**
37 * The identifier of the invalidation batch.
38 */
39 invalidationId: string;
40 /**
41 * The distribution the invalidation ran against.
42 */
43 distributionId: string;
44 /**
45 * The version prop that triggered this invalidation batch.
46 */
47 version: string;
48 /**
49 * Current status of the invalidation (`InProgress` or `Completed`).
50 */
51 status: string;
52 /**
53 * The path patterns that were invalidated.
54 */
55 paths: string[];
56 /**
57 * When the invalidation batch was created.
58 */
59 createTime: Date | undefined;
60 },
61 never,
62 Providers
63> {}
64
65/**
66 * A CloudFront cache invalidation request.
67 *
68 * `Invalidation` is a helper resource for website deployments that need to
69 * clear selected CloudFront cache paths after asset updates.
70 * ### Creating Invalidations
71 * **Example:** Invalidate The Entire Distribution
72 * ```typescript
73 * const invalidation = yield* Invalidation("WebsiteInvalidation", {
74 * distributionId: distribution.distributionId,
75 * version: files.version,
76 * });
77 * ```
78 *
79 * @resource
80 */
81export const Invalidation = Resource<Invalidation>(
82 "AWS.CloudFront.Invalidation",
83);
84
85const defaultPaths = ["/*"];
86
87class InvalidationInProgress extends Data.TaggedError(
88 "InvalidationInProgress",
89)<{
90 message: string;
91}> {}
92
93export const InvalidationProvider = () =>
94 Provider.effect(
95 Invalidation,
96 Effect.gen(function* () {
97 const waitForCompletion = Effect.fn(function* (
98 distributionId: string,
99 invalidationId: string,
100 ) {
101 yield* Effect.logInfo(
102 `CloudFront Invalidation wait: polling ${invalidationId} for distribution ${distributionId}`,
103 );
104 return yield* cloudfront
105 .getInvalidation({
106 DistributionId: distributionId,
107 Id: invalidationId,
108 })
109 .pipe(
110 Effect.map((response) => response.Invalidation),
111 Effect.flatMap((invalidation) =>
112 invalidation?.Status === "Completed"
113 ? Effect.gen(function* () {
114 yield* Effect.logInfo(
115 `CloudFront Invalidation wait: ${invalidationId} completed`,
116 );
117 return invalidation;
118 })
119 : Effect.gen(function* () {
120 yield* Effect.logInfo(
121 `CloudFront Invalidation wait: ${invalidationId} status=${invalidation?.Status ?? "unknown"}`,
122 );
123 return yield* Effect.fail(
124 new InvalidationInProgress({
125 message: `Invalidation ${invalidationId} is still in progress`,
126 }),
127 );
128 }),
129 ),
130 Effect.retry({
131 while: (error) => error._tag === "InvalidationInProgress",
132 schedule: Schedule.max([
133 Schedule.fixed("2 seconds"),
134 Schedule.recurs(120),
135 ]),
136 }),
137 );
138 });
139
140 const createInvalidation = Effect.fn(function* (
141 props: InvalidationProps,
142 ) {
143 yield* Effect.logInfo(
144 `CloudFront Invalidation create: distribution=${props.distributionId} version=${props.version} paths=${(props.paths ?? defaultPaths).length} wait=${props.wait ?? false}`,
145 );
146 const response = yield* cloudfront.createInvalidation({
147 DistributionId: props.distributionId,
148 InvalidationBatch: {
149 CallerReference: props.version,
150 Paths: {
151 Quantity: (props.paths ?? defaultPaths).length,
152 Items: props.paths ?? defaultPaths,
153 },
154 },
155 });
156
157 yield* Effect.logInfo(
158 `CloudFront Invalidation create: created ${response.Invalidation?.Id ?? "missing"} status=${response.Invalidation?.Status ?? "unknown"}`,
159 );
160 const invalidation = props.wait
161 ? yield* waitForCompletion(
162 props.distributionId,
163 response.Invalidation?.Id!,
164 )
165 : response.Invalidation;
166
167 if (!invalidation?.Id) {
168 return yield* Effect.fail(
169 new Error("createInvalidation returned no invalidation"),
170 );
171 }
172
173 return invalidation;
174 });
175
176 return {
177 stables: ["distributionId", "version"],
178 // Non-listable: an Invalidation is an ephemeral, immutable ledger entry
179 // keyed by {distributionId, invalidationId}. It completes on its own and
180 // cannot be deleted (`delete` is a no-op), so there is no persistent
181 // "current resource" to enumerate for nuke. `listInvalidations` only
182 // exposes historical, undeletable entries per distribution.
183 list: () => Effect.succeed([]),
184 diff: Effect.fn(function* ({ olds, news: _news }) {
185 if (!isResolved(_news)) return undefined;
186 const news = _news as typeof olds;
187 yield* Effect.logInfo(
188 `CloudFront Invalidation diff: oldDistribution=${olds.distributionId} newDistribution=${news.distributionId} oldVersion=${olds.version} newVersion=${news.version}`,
189 );
190 if (
191 olds.distributionId !== news.distributionId ||
192 olds.version !== news.version
193 ) {
194 yield* Effect.logInfo(
195 `CloudFront Invalidation diff: replacing invalidation for distribution=${news.distributionId}`,
196 );
197 return { action: "replace" } as const;
198 }
199 }),
200 reconcile: Effect.fn(function* ({ news, output, session }) {
201 // An invalidation is an immutable ledger entry, not a mutable
202 // resource. If we already issued one for this logical id and
203 // version, return its attributes unchanged. The `diff` above
204 // forces a `replace` whenever `distributionId` or `version`
205 // changes, so the engine creates a fresh invalidation that way.
206 if (output?.invalidationId) {
207 yield* session.note(output.invalidationId);
208 return output;
209 }
210
211 // Ensure — issue the invalidation. CloudFront uses
212 // `CallerReference` (= `version`) for idempotency: the same
213 // version submitted twice returns the same invalidation.
214 const invalidation = yield* createInvalidation(news);
215 yield* Effect.logInfo(
216 `CloudFront Invalidation reconcile: storing ${invalidation.Id} for distribution=${news.distributionId}`,
217 );
218 yield* session.note(invalidation.Id);
219 return {
220 invalidationId: invalidation.Id,
221 distributionId: news.distributionId,
222 version: news.version,
223 status: invalidation.Status ?? "InProgress",
224 paths: news.paths ?? defaultPaths,
225 createTime: invalidation.CreateTime,
226 };
227 }),
228 delete: Effect.fn(function* () {}),
229 };
230 }),
231 );