1import * as cloudfront from "@distilled.cloud/aws/cloudfront";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { isResolved } from "../../Diff.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 FunctionProps {
12 /**
13 * CloudFront Function name. If omitted, a deterministic name is generated.
14 */
15 name?: string;
16 /**
17 * CloudFront Function runtime.
18 * @default "cloudfront-js-2.0"
19 */
20 runtime?: cloudfront.FunctionRuntime;
21 /**
22 * Optional function comment.
23 */
24 comment?: string;
25 /**
26 * JavaScript source code for the function.
27 */
28 code: string;
29 /**
30 * Optional associated KeyValueStore ARNs.
31 */
32 keyValueStoreArns?: string[];
33}
34
35export interface Function extends Resource<
36 "AWS.CloudFront.Function",
37 FunctionProps,
38 {
39 /**
40 * CloudFront function ARN.
41 */
42 functionArn: string;
43 /**
44 * Function name.
45 */
46 functionName: string;
47 /**
48 * Runtime currently configured for the function.
49 */
50 runtime: cloudfront.FunctionRuntime;
51 /**
52 * Current comment.
53 */
54 comment: string;
55 /**
56 * Deployment stage for the function.
57 */
58 stage: cloudfront.FunctionStage;
59 /**
60 * Current status.
61 */
62 status: string;
63 /**
64 * Last modified time.
65 */
66 lastModifiedTime: Date | undefined;
67 /**
68 * Latest entity tag for update/delete operations.
69 */
70 etag: string | undefined;
71 /**
72 * Associated KeyValueStore ARNs.
73 */
74 keyValueStoreArns: string[];
75 },
76 never,
77 Providers
78> {}
79
80/**
81 * A CloudFront Function for viewer request and response customization.
82 *
83 * CloudFront Functions are lightweight JavaScript handlers that run at the
84 * edge and can be attached to distribution cache behaviors.
85 * ### Creating Functions
86 * **Example:** Viewer Request Function
87 * ```typescript
88 * const fn = yield* Function("RouterRequestFunction", {
89 * code: `
90 * async function handler(event) {
91 * event.request.headers["x-forwarded-host"] = {
92 * value: event.request.headers.host.value,
93 * };
94 * return event.request;
95 * }
96 * `,
97 * });
98 * ```
99 *
100 * @resource
101 */
102export const Function = Resource<Function>("AWS.CloudFront.Function");
103
104export const FunctionProvider = () =>
105 Provider.effect(
106 Function,
107 Effect.gen(function* () {
108 const describe = Effect.fn(function* (
109 name: string,
110 stage?: cloudfront.FunctionStage,
111 ) {
112 return yield* cloudfront
113 .describeFunction({
114 Name: name,
115 Stage: stage,
116 })
117 .pipe(
118 Effect.catchTag("NoSuchFunctionExists", () =>
119 Effect.succeed(undefined),
120 ),
121 );
122 });
123
124 const getCurrent = Effect.fn(function* (name: string) {
125 const live = yield* describe(name, "LIVE");
126 if (live?.FunctionSummary) {
127 return live;
128 }
129 return yield* describe(name, "DEVELOPMENT");
130 });
131
132 const getDevelopmentEtag = Effect.fn(function* (name: string) {
133 const current = yield* describe(name, "DEVELOPMENT");
134 return current?.ETag;
135 });
136
137 const publish = Effect.fn(function* (
138 name: string,
139 etag: string | undefined,
140 ) {
141 if (!etag) {
142 return yield* Effect.fail(
143 new Error(
144 `CloudFront Function '${name}' is missing an ETag for publish`,
145 ),
146 );
147 }
148 yield* cloudfront.publishFunction({
149 Name: name,
150 IfMatch: etag,
151 });
152 return yield* describe(name, "LIVE");
153 });
154
155 return {
156 stables: ["functionArn", "functionName"],
157 // CloudFront is global; listFunctions enumerates every function in the
158 // account. It is non-paginated in distilled but uses Marker/NextMarker,
159 // so we page manually until NextMarker is absent. All CloudFront
160 // functions are custom (no AWS-managed ones), so every item is ours.
161 list: () =>
162 Effect.gen(function* () {
163 const items: Function["Attributes"][] = [];
164 let marker: string | undefined = undefined;
165 do {
166 const listed: cloudfront.ListFunctionsResult =
167 yield* cloudfront.listFunctions({ Marker: marker });
168 for (const summary of listed.FunctionList?.Items ?? []) {
169 items.push(toAttrs(summary, undefined, summary.Name ?? ""));
170 }
171 marker = listed.FunctionList?.NextMarker;
172 } while (marker);
173 return items;
174 }),
175 diff: Effect.fn(function* ({ id, olds, news: _news }) {
176 if (!isResolved(_news)) return undefined;
177 const news = _news as typeof olds;
178 if (
179 (yield* createName(id, olds ?? {})) !==
180 (yield* createName(id, news))
181 ) {
182 return { action: "replace" } as const;
183 }
184 }),
185 read: Effect.fn(function* ({ id, olds, output }) {
186 const name =
187 output?.functionName ?? (yield* createName(id, olds ?? {}));
188 const current = yield* getCurrent(name);
189 if (!current?.FunctionSummary) {
190 return undefined;
191 }
192 return toAttrs(current.FunctionSummary, current.ETag, name);
193 }),
194 reconcile: Effect.fn(function* ({ id, news, output, session }) {
195 const name = output?.functionName ?? (yield* createName(id, news));
196
197 // Observe — describe the function (LIVE stage preferred,
198 // DEVELOPMENT fallback). We don't trust `output` because the
199 // function may have been deleted out-of-band.
200 const observedDevelopment = yield* describe(name, "DEVELOPMENT");
201
202 // Ensure — create the function if it's missing. Tolerate
203 // `FunctionAlreadyExists` as a race with a peer reconciler. The
204 // newly created function lives in DEVELOPMENT; we publish it to
205 // LIVE below, in the same flow that handles already-existing
206 // functions.
207 const developmentEtag = observedDevelopment
208 ? observedDevelopment.ETag
209 : yield* Effect.gen(function* () {
210 const created = yield* cloudfront
211 .createFunction({
212 Name: name,
213 FunctionConfig: {
214 Comment: news.comment ?? "",
215 Runtime: news.runtime ?? "cloudfront-js-2.0",
216 KeyValueStoreAssociations: toKvAssociations(
217 news.keyValueStoreArns,
218 ),
219 },
220 FunctionCode: new TextEncoder().encode(news.code),
221 })
222 .pipe(
223 Effect.catchTag("FunctionAlreadyExists", () =>
224 describe(name, "DEVELOPMENT").pipe(
225 Effect.flatMap((existing) =>
226 existing
227 ? Effect.succeed(existing)
228 : Effect.die(
229 `CloudFront Function '${name}' already exists but could not be recovered`,
230 ),
231 ),
232 ),
233 ),
234 Effect.retry({
235 while: (error) =>
236 error._tag === "InvalidArgument" &&
237 isKeyValueStoreAssociationPending(error),
238 schedule: cappedCloudFrontRetrySchedule,
239 }),
240 );
241 return created.ETag;
242 });
243
244 // Sync — push desired config/code to DEVELOPMENT when the
245 // function already existed. Skip when we just created it (the
246 // creation already used the desired config).
247 let etagToPublish = developmentEtag;
248 if (observedDevelopment) {
249 const updated = yield* cloudfront
250 .updateFunction({
251 Name: name,
252 IfMatch: observedDevelopment.ETag!,
253 FunctionConfig: {
254 Comment: news.comment ?? "",
255 Runtime:
256 news.runtime ??
257 observedDevelopment.FunctionSummary?.FunctionConfig
258 .Runtime ??
259 "cloudfront-js-2.0",
260 KeyValueStoreAssociations: toKvAssociations(
261 news.keyValueStoreArns,
262 ),
263 },
264 FunctionCode: new TextEncoder().encode(news.code),
265 })
266 .pipe(
267 Effect.retry({
268 while: (error) =>
269 error._tag === "InvalidArgument" &&
270 isKeyValueStoreAssociationPending(error),
271 schedule: cappedCloudFrontRetrySchedule,
272 }),
273 );
274 etagToPublish = updated.ETag;
275 }
276
277 // Publish DEVELOPMENT → LIVE so consumers see the desired code.
278 const live = yield* publish(name, etagToPublish);
279 if (!live?.FunctionSummary) {
280 return yield* Effect.die(
281 "publishFunction returned no function summary",
282 );
283 }
284
285 yield* session.note(name);
286 return toAttrs(live.FunctionSummary, live.ETag, name);
287 }),
288 delete: Effect.fn(function* ({ output }) {
289 yield* Effect.gen(function* () {
290 const developmentEtag = yield* getDevelopmentEtag(
291 output.functionName,
292 );
293 if (!developmentEtag) {
294 yield* Effect.logInfo(
295 `CloudFront Function delete: ${output.functionName} already absent`,
296 );
297 return;
298 }
299
300 yield* Effect.logInfo(
301 `CloudFront Function delete: deleting ${output.functionName} stage=DEVELOPMENT etag=${developmentEtag}`,
302 );
303 yield* cloudfront.deleteFunction({
304 Name: output.functionName,
305 IfMatch: developmentEtag,
306 });
307 }).pipe(
308 Effect.catchTag("NoSuchFunctionExists", () => Effect.void),
309 Effect.tapError((error) =>
310 isFunctionDeletePending(error)
311 ? Effect.logInfo(
312 `CloudFront Function delete: ${output.functionName} not ready yet (${error._tag}), retrying with capped exponential backoff`,
313 )
314 : Effect.logError(
315 `CloudFront Function delete: ${output.functionName} failed with ${String(error)}`,
316 ),
317 ),
318 Effect.retry({
319 while: isFunctionDeletePending,
320 schedule: cappedCloudFrontRetrySchedule,
321 }),
322 );
323 }),
324 };
325 }),
326 );
327
328const isFunctionDeletePending = (error: {
329 _tag?: string;
330}): error is cloudfront.FunctionInUse | cloudfront.PreconditionFailed =>
331 error._tag === "FunctionInUse" || error._tag === "PreconditionFailed";
332
333const isKeyValueStoreAssociationPending = (error: { message?: string }) => {
334 const message = error.message ?? "";
335 return (
336 message.includes("KeyValueStoreAssociationArn") &&
337 message.includes("cannot be associated before the resource is provisioned")
338 );
339};
340
341const cappedCloudFrontRetrySchedule = Schedule.max([
342 Schedule.exponential("100 millis"),
343 Schedule.recurs(24),
344]).pipe(
345 Schedule.modifyDelay(({ duration }) =>
346 Effect.succeed(
347 Duration.isGreaterThan(duration, Duration.seconds(2))
348 ? Duration.seconds(2)
349 : duration,
350 ),
351 ),
352);
353
354const createName = (id: string, props: FunctionProps) =>
355 props.name
356 ? Effect.succeed(props.name)
357 : createPhysicalName({
358 id,
359 maxLength: 64,
360 lowercase: true,
361 });
362
363const toKvAssociations = (
364 arns: string[] | undefined,
365): cloudfront.KeyValueStoreAssociations | undefined =>
366 arns && arns.length > 0
367 ? {
368 Quantity: arns.length,
369 Items: arns.map((KeyValueStoreARN) => ({ KeyValueStoreARN })),
370 }
371 : undefined;
372
373const toAttrs = (
374 summary: cloudfront.FunctionSummary,
375 etag: string | undefined,
376 fallbackName: string,
377): Function["Attributes"] => ({
378 functionArn: summary.FunctionMetadata.FunctionARN,
379 functionName: summary.Name || fallbackName,
380 runtime: summary.FunctionConfig.Runtime,
381 comment: summary.FunctionConfig.Comment ?? "",
382 stage: summary.FunctionMetadata.Stage ?? "DEVELOPMENT",
383 status: summary.Status ?? "UNKNOWN",
384 lastModifiedTime: summary.FunctionMetadata.LastModifiedTime,
385 etag,
386 keyValueStoreArns:
387 summary.FunctionConfig.KeyValueStoreAssociations?.Items?.flatMap((item) =>
388 item.KeyValueStoreARN ? [item.KeyValueStoreARN] : [],
389 ) ?? [],
390});