1import * as cloudfront from "@distilled.cloud/aws/cloudfront";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import { isResolved } from "../../Diff.ts";
5import { toWireSeconds } from "../../Util/Duration.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10
11export interface CachePolicyProps {
12 /**
13 * Name of the cache policy. If omitted, a deterministic name is generated.
14 *
15 * Names must be unique per AWS account. Changing the name triggers
16 * a replacement.
17 */
18 name?: string;
19 /**
20 * Optional comment describing the policy.
21 */
22 comment?: string;
23 /**
24 * Minimum amount of time that objects stay in the cache (e.g.
25 * `"1 minute"` or `Duration.minutes(1)`; a bare number is milliseconds).
26 */
27 minTTL: Duration.Input;
28 /**
29 * Default amount of time that objects stay in the cache when the origin
30 * does not send `Cache-Control` or `Expires` headers (e.g. `"1 hour"`).
31 */
32 defaultTTL?: Duration.Input;
33 /**
34 * Maximum amount of time that objects stay in the cache (e.g. `"1 day"`).
35 */
36 maxTTL?: Duration.Input;
37 /**
38 * Controls which request values become part of the cache key and which
39 * additional headers/cookies/query strings CloudFront forwards to the origin.
40 */
41 parametersInCacheKeyAndForwardedToOrigin?: cloudfront.ParametersInCacheKeyAndForwardedToOrigin;
42}
43
44export interface CachePolicy extends Resource<
45 "AWS.CloudFront.CachePolicy",
46 CachePolicyProps,
47 {
48 /**
49 * CloudFront-assigned cache policy identifier.
50 */
51 cachePolicyId: string;
52 /**
53 * Name of the cache policy.
54 */
55 name: string;
56 /**
57 * Most recent entity tag for update/delete operations.
58 */
59 etag: string | undefined;
60 /**
61 * Current comment on the policy.
62 */
63 comment: string | undefined;
64 /**
65 * Current minimum TTL.
66 */
67 minTTL: number;
68 /**
69 * Current default TTL.
70 */
71 defaultTTL: number | undefined;
72 /**
73 * Current maximum TTL.
74 */
75 maxTTL: number | undefined;
76 /**
77 * Current cache-key/forwarded-value configuration.
78 */
79 parametersInCacheKeyAndForwardedToOrigin:
80 | cloudfront.ParametersInCacheKeyAndForwardedToOrigin
81 | undefined;
82 },
83 never,
84 Providers
85> {}
86
87/**
88 * A CloudFront cache policy.
89 *
90 * Cache policies determine the values CloudFront includes in the cache key,
91 * the headers, cookies and query strings it forwards to the origin, and the
92 * TTL bounds for cached responses. Policies are referenced by ID on a
93 * Distribution's default behavior or per-path cache behaviors.
94 *
95 * For AWS-managed policies (CachingOptimized, CachingDisabled,
96 * AllViewerExceptHostHeader) reference them by ID via the constants in
97 * {@link ManagedPolicies} instead of creating a custom policy.
98 * ### Creating Cache Policies
99 * **Example:** Cache by query string and Authorization header
100 * ```typescript
101 * const cachePolicy = yield* CachePolicy("ApiCachePolicy", {
102 * comment: "Cache GETs by query string + Authorization",
103 * minTTL: 0,
104 * defaultTTL: "1 minute",
105 * maxTTL: "1 hour",
106 * parametersInCacheKeyAndForwardedToOrigin: {
107 * EnableAcceptEncodingGzip: true,
108 * EnableAcceptEncodingBrotli: true,
109 * HeadersConfig: {
110 * HeaderBehavior: "whitelist",
111 * Headers: { Quantity: 1, Items: ["Authorization"] },
112 * },
113 * CookiesConfig: { CookieBehavior: "none" },
114 * QueryStringsConfig: { QueryStringBehavior: "all" },
115 * },
116 * });
117 * ```
118 *
119 * @resource
120 */
121export const CachePolicy = Resource<CachePolicy>("AWS.CloudFront.CachePolicy");
122
123export const CachePolicyProvider = () =>
124 Provider.effect(
125 CachePolicy,
126 Effect.gen(function* () {
127 const getById = Effect.fn(function* (id: string) {
128 const config = yield* cloudfront
129 .getCachePolicyConfig({ Id: id })
130 .pipe(
131 Effect.catchTag("NoSuchCachePolicy", () =>
132 Effect.succeed(undefined),
133 ),
134 );
135 if (!config?.CachePolicyConfig) return undefined;
136 return { config: config.CachePolicyConfig, etag: config.ETag };
137 });
138
139 const getByName = Effect.fn(function* (name: string) {
140 const listed = yield* cloudfront.listCachePolicies({ Type: "custom" });
141 const summary = listed.CachePolicyList?.Items?.find(
142 (item) => item.CachePolicy?.CachePolicyConfig?.Name === name,
143 );
144 if (!summary?.CachePolicy?.Id) return undefined;
145 return yield* getById(summary.CachePolicy.Id).pipe(
146 Effect.map((found) =>
147 found ? { id: summary.CachePolicy.Id, ...found } : undefined,
148 ),
149 );
150 });
151
152 const buildConfig = (
153 name: string,
154 props: CachePolicyProps,
155 ): cloudfront.CachePolicyConfig => ({
156 Name: name,
157 Comment: props.comment,
158 MinTTL: toWireSeconds(props.minTTL)!,
159 DefaultTTL: toWireSeconds(props.defaultTTL),
160 MaxTTL: toWireSeconds(props.maxTTL),
161 ParametersInCacheKeyAndForwardedToOrigin:
162 props.parametersInCacheKeyAndForwardedToOrigin,
163 });
164
165 const toAttrs = (
166 id: string,
167 config: cloudfront.CachePolicyConfig,
168 etag: string | undefined,
169 ) => ({
170 cachePolicyId: id,
171 name: config.Name,
172 etag,
173 comment: config.Comment,
174 minTTL: config.MinTTL,
175 defaultTTL: config.DefaultTTL,
176 maxTTL: config.MaxTTL,
177 parametersInCacheKeyAndForwardedToOrigin:
178 config.ParametersInCacheKeyAndForwardedToOrigin,
179 });
180
181 return {
182 stables: ["cachePolicyId"],
183 diff: Effect.fn(function* ({ id, news, olds }) {
184 if (!isResolved(news)) return undefined;
185 if (
186 (yield* createName(id, olds ?? {})) !==
187 (yield* createName(id, news))
188 ) {
189 return { action: "replace" } as const;
190 }
191 }),
192 read: Effect.fn(function* ({ id, olds, output }) {
193 if (output?.cachePolicyId) {
194 const found = yield* getById(output.cachePolicyId);
195 if (found)
196 return toAttrs(output.cachePolicyId, found.config, found.etag);
197 }
198 const name = yield* createName(id, olds ?? {});
199 const found = yield* getByName(name);
200 if (!found) return undefined;
201 return toAttrs(found.id, found.config, found.etag);
202 }),
203 // CloudFront is global (no region). `listCachePolicies` returns both
204 // AWS-managed and custom policies; we filter to `Type: "custom"` since
205 // those are the only ones we create/delete. The op is marker-paginated
206 // (no `.pages`), so we loop until `NextMarker` is exhausted and hydrate
207 // each summary's ETag via `getById` so every row matches read().
208 list: () =>
209 Effect.gen(function* () {
210 const items: ReturnType<typeof toAttrs>[] = [];
211 let marker: string | undefined = undefined;
212 do {
213 const listed: cloudfront.ListCachePoliciesResult =
214 yield* cloudfront.listCachePolicies({
215 Type: "custom",
216 Marker: marker,
217 });
218 for (const summary of listed.CachePolicyList?.Items ?? []) {
219 if (summary.Type !== "custom") continue;
220 const id = summary.CachePolicy?.Id;
221 const config = summary.CachePolicy?.CachePolicyConfig;
222 if (!id || !config) continue;
223 const found = yield* getById(id);
224 items.push(toAttrs(id, found?.config ?? config, found?.etag));
225 }
226 marker = listed.CachePolicyList?.NextMarker;
227 } while (marker);
228 return items;
229 }),
230 reconcile: Effect.fn(function* ({ id, news, output, session }) {
231 const name = yield* createName(id, news);
232
233 // Observe — locate the policy by id (cached on `output`) or by
234 // name. Trust observed cloud state, not stale `olds`.
235 let observed = output?.cachePolicyId
236 ? yield* getById(output.cachePolicyId).pipe(
237 Effect.map((found) =>
238 found ? { id: output.cachePolicyId, ...found } : undefined,
239 ),
240 )
241 : undefined;
242 if (!observed) {
243 observed = yield* getByName(name);
244 }
245
246 // Ensure — create the policy if it's missing. Tolerate
247 // `CachePolicyAlreadyExists` as a race with a peer reconciler:
248 // re-read by name and continue with the sync path.
249 if (!observed) {
250 const created = yield* cloudfront
251 .createCachePolicy({
252 CachePolicyConfig: buildConfig(name, news),
253 })
254 .pipe(
255 Effect.catchTag("CachePolicyAlreadyExists", () =>
256 getByName(name).pipe(
257 Effect.flatMap((existing) =>
258 existing
259 ? Effect.succeed({
260 CachePolicy: {
261 Id: existing.id,
262 LastModifiedTime: new Date(),
263 CachePolicyConfig: existing.config,
264 },
265 ETag: existing.etag,
266 Location: undefined,
267 })
268 : Effect.fail(
269 new Error(
270 `Cache policy '${name}' already exists but could not be recovered`,
271 ),
272 ),
273 ),
274 ),
275 ),
276 );
277 if (!created.CachePolicy?.Id) {
278 return yield* Effect.fail(
279 new Error("createCachePolicy returned no identifier"),
280 );
281 }
282 yield* session.note(created.CachePolicy.Id);
283 return toAttrs(
284 created.CachePolicy.Id,
285 created.CachePolicy.CachePolicyConfig,
286 created.ETag,
287 );
288 }
289
290 // Sync — diff observed config against desired and patch via
291 // `updateCachePolicy` with the freshly observed ETag.
292 const desired = buildConfig(observed.config.Name, news);
293 const updated = yield* cloudfront.updateCachePolicy({
294 Id: observed.id,
295 IfMatch: observed.etag,
296 CachePolicyConfig: desired,
297 });
298 if (!updated.CachePolicy?.Id) {
299 return yield* Effect.fail(
300 new Error("updateCachePolicy returned no identifier"),
301 );
302 }
303 yield* session.note(observed.id);
304 return toAttrs(
305 updated.CachePolicy.Id,
306 updated.CachePolicy.CachePolicyConfig,
307 updated.ETag,
308 );
309 }),
310 delete: Effect.fn(function* ({ output }) {
311 const current = yield* getById(output.cachePolicyId);
312 if (!current) return;
313 yield* cloudfront
314 .deleteCachePolicy({
315 Id: output.cachePolicyId,
316 IfMatch: current.etag,
317 })
318 .pipe(Effect.catchTag("NoSuchCachePolicy", () => Effect.void));
319 }),
320 };
321 }),
322 );
323
324const createName = (id: string, props: CachePolicyProps) =>
325 props.name
326 ? Effect.succeed(props.name)
327 : createPhysicalName({ id, maxLength: 128, lowercase: true });