1import * as cloudfront from "@distilled.cloud/aws/cloudfront";
2import * as Option from "effect/Option";
3import * as Stream from "effect/Stream";
4import * as kvs from "@distilled.cloud/aws/cloudfront-keyvaluestore";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import type { HttpClient } from "effect/http/HttpClient";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import type { Providers } from "../Providers.ts";
11import type { Credentials } from "../Credentials.ts";
12import type { Region } from "../Region.ts";
13import {
14 extractValue,
15 getKvsEtag,
16 isKvsPreconditionFailed,
17 cappedKvsRetrySchedule,
18 retryForKvsReadiness,
19 withKvsRegionFn,
20} from "./common.ts";
21
22export interface KvRoutesUpdateProps {
23 /** ARN of the CloudFront KeyValueStore. */
24 store: string;
25 /** Namespace prefix. The full key is `{namespace}:{key}`. */
26 namespace: string;
27 /** Key within the namespace (typically "routes"). */
28 key: string;
29 /** The route entry string to add/manage (format: "type,namespace,hostPattern,pathPrefix"). */
30 entry: string;
31}
32
33export interface KvRoutesUpdate extends Resource<
34 "AWS.CloudFront.KvRoutesUpdate",
35 KvRoutesUpdateProps,
36 {
37 /**
38 * The ARN of the key value store holding the routes.
39 */
40 store: string;
41 /**
42 * The namespace prefix the routes were written under.
43 */
44 namespace: string;
45 /**
46 * The key the route table was written to.
47 */
48 key: string;
49 /**
50 * The serialized route table value that was written.
51 */
52 entry: string;
53 },
54 never,
55 Providers
56> {}
57
58/**
59 * Manages a single route entry in a JSON array stored in a CloudFront KeyValueStore.
60 *
61 * The routes array is stored at key `{namespace}:{key}` and supports automatic
62 * chunking when the serialized array exceeds 1000 characters.
63 * ### Managing Routes
64 * **Example:** Add A Route Entry
65 * ```typescript
66 * const update = yield* KvRoutesUpdate("MyRoute", {
67 * store: store.keyValueStoreArn,
68 * namespace: "app",
69 * key: "routes",
70 * entry: "site,mysite,*,/",
71 * });
72 * ```
73 *
74 * @resource
75 */
76export const KvRoutesUpdate = Resource<KvRoutesUpdate>(
77 "AWS.CloudFront.KvRoutesUpdate",
78);
79
80const CHUNK_SIZE = 1000;
81
82export const KvRoutesUpdateProvider = () =>
83 Provider.effect(
84 KvRoutesUpdate,
85 Effect.gen(function* () {
86 const getRoutes = Effect.fn(function* (store: string, fullKey: string) {
87 const res = yield* kvs
88 .getKey({ KvsARN: store, Key: fullKey })
89 .pipe(
90 Effect.catchTag("ResourceNotFoundException", () =>
91 Effect.succeed(undefined),
92 ),
93 );
94
95 if (!res) {
96 return { routes: [] as string[], chunkNum: 1 };
97 }
98
99 const raw = extractValue(res.Value);
100 const parsed = JSON.parse(raw);
101
102 if (parsed && typeof parsed === "object" && "parts" in parsed) {
103 const parts: number = parsed.parts;
104 const chunks: string[] = [];
105 for (let i = 0; i < parts; i++) {
106 const chunkRes = yield* kvs.getKey({
107 KvsARN: store,
108 Key: `${fullKey}:${i}`,
109 });
110 chunks.push(extractValue(chunkRes.Value));
111 }
112 return {
113 routes: JSON.parse(chunks.join("")) as string[],
114 chunkNum: parts,
115 };
116 }
117
118 return { routes: parsed as string[], chunkNum: 1 };
119 });
120
121 const setRoutes = Effect.fn(function* (
122 store: string,
123 etag: string,
124 fullKey: string,
125 routes: string[],
126 oldChunkNum: number,
127 ) {
128 const serialized = JSON.stringify(routes);
129 const puts: kvs.PutKeyRequestListItem[] = [];
130 const deletes: kvs.DeleteKeyRequestListItem[] = [];
131
132 if (serialized.length > CHUNK_SIZE) {
133 const chunkCount = Math.ceil(serialized.length / CHUNK_SIZE);
134 puts.push({
135 Key: fullKey,
136 Value: JSON.stringify({ parts: chunkCount }),
137 });
138 for (let i = 0; i < chunkCount; i++) {
139 puts.push({
140 Key: `${fullKey}:${i}`,
141 Value: serialized.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE),
142 });
143 }
144 if (oldChunkNum > chunkCount) {
145 for (let i = chunkCount; i < oldChunkNum; i++) {
146 deletes.push({ Key: `${fullKey}:${i}` });
147 }
148 }
149 } else {
150 puts.push({ Key: fullKey, Value: serialized });
151 if (oldChunkNum > 1) {
152 for (let i = 0; i < oldChunkNum; i++) {
153 deletes.push({ Key: `${fullKey}:${i}` });
154 }
155 }
156 }
157
158 yield* kvs.updateKeys({
159 KvsARN: store,
160 IfMatch: etag,
161 Puts: puts.length > 0 ? puts : undefined,
162 Deletes: deletes.length > 0 ? deletes : undefined,
163 });
164 });
165
166 const deleteKey = Effect.fn(function* (
167 store: string,
168 etag: string,
169 fullKey: string,
170 oldChunkNum: number,
171 ) {
172 const deletes: kvs.DeleteKeyRequestListItem[] = [{ Key: fullKey }];
173 if (oldChunkNum > 1) {
174 for (let i = 0; i < oldChunkNum; i++) {
175 deletes.push({ Key: `${fullKey}:${i}` });
176 }
177 }
178 yield* kvs.updateKeys({
179 KvsARN: store,
180 IfMatch: etag,
181 Deletes: deletes,
182 });
183 });
184
185 const deleteOp = (
186 props: KvRoutesUpdateProps,
187 ): Effect.Effect<
188 void,
189 kvs.UpdateKeysError,
190 Credentials | Region | HttpClient
191 > =>
192 Effect.gen(function* () {
193 const fullKey = `${props.namespace}:${props.key}`;
194 const etag = yield* getKvsEtag(props.store);
195 const { routes, chunkNum } = yield* getRoutes(props.store, fullKey);
196 if (!routes.includes(props.entry)) return;
197 const filtered = routes.filter((r) => r !== props.entry);
198 if (filtered.length === 0) {
199 yield* deleteKey(props.store, etag, fullKey, chunkNum);
200 } else {
201 yield* setRoutes(props.store, etag, fullKey, filtered, chunkNum);
202 }
203 }).pipe(
204 Effect.retry({
205 while: (error) =>
206 error._tag === "ValidationException" &&
207 isKvsPreconditionFailed(error),
208 schedule: cappedKvsRetrySchedule,
209 }),
210 );
211
212 const upsertEntry = (
213 store: string,
214 fullKey: string,
215 entryToAdd: string,
216 entryToRemove: string | undefined,
217 ): Effect.Effect<
218 void,
219 kvs.UpdateKeysError,
220 HttpClient | Region | Credentials
221 > =>
222 Effect.gen(function* () {
223 const etag = yield* getKvsEtag(store);
224 const { routes, chunkNum } = yield* getRoutes(store, fullKey);
225 const filtered =
226 entryToRemove !== undefined
227 ? routes.filter((r) => r !== entryToRemove)
228 : routes.slice();
229 if (!filtered.includes(entryToAdd)) {
230 filtered.push(entryToAdd);
231 }
232 yield* setRoutes(store, etag, fullKey, filtered, chunkNum);
233 }).pipe(
234 Effect.retry({
235 while: (error) =>
236 error._tag === "ValidationException" &&
237 isKvsPreconditionFailed(error),
238 schedule: cappedKvsRetrySchedule,
239 }),
240 );
241
242 return {
243 // Non-listable: this resource is an update operation that manages a
244 // single route entry inside a JSON array stored at a KV store key. It is
245 // keyed entirely by {store, namespace, key, entry}; there is no API that
246 // enumerates individual route-entry updates, so list returns [].
247 list: () => Effect.succeed([]),
248 read: withKvsRegionFn(
249 Effect.fn(function* ({ output }) {
250 return output;
251 }),
252 ),
253 reconcile: withKvsRegionFn(
254 Effect.fn(function* ({ news, output }) {
255 return yield* retryForKvsReadiness(
256 Effect.gen(function* () {
257 // Observe — figure out whether the prior entry lived at a
258 // different location. When location changes (or this is
259 // the first reconcile), we strip the old entry from the
260 // old routes array first, then add the new one to the
261 // new array.
262 const movedLocation =
263 output !== undefined &&
264 (output.store !== news.store ||
265 output.namespace !== news.namespace ||
266 output.key !== news.key);
267
268 if (movedLocation) {
269 yield* deleteOp(output);
270 }
271
272 // Sync — append the desired entry to the routes array at
273 // the new location. If the entry was previously at the
274 // same location, drop the old value from the in-memory
275 // array before re-appending so a value change is treated
276 // as upsert-by-replace.
277 const fullKey = `${news.namespace}:${news.key}`;
278 const previousEntry =
279 !movedLocation && output !== undefined
280 ? output.entry
281 : undefined;
282 yield* upsertEntry(
283 news.store,
284 fullKey,
285 news.entry,
286 previousEntry,
287 );
288
289 return {
290 store: news.store,
291 namespace: news.namespace,
292 key: news.key,
293 entry: news.entry,
294 };
295 }),
296 );
297 }),
298 ),
299 delete: withKvsRegionFn(
300 Effect.fn(function* ({ output }) {
301 yield* retryForKvsReadiness(
302 deleteOp({
303 store: output.store,
304 namespace: output.namespace,
305 key: output.key,
306 entry: output.entry,
307 }).pipe(
308 Effect.catchTag("ConflictException", (error) =>
309 // The data plane reports ConflictException for deleted stores.
310 // Confirm absence through the control plane before ignoring it.
311 cloudfront.listKeyValueStores.pages({}).pipe(
312 Stream.flatMap((page) =>
313 Stream.fromIterable(page.KeyValueStoreList?.Items ?? []),
314 ),
315 Stream.filter((store) => store.ARN === output.store),
316 Stream.runHead,
317 Effect.flatMap((store) =>
318 Option.isNone(store) ? Effect.void : Effect.fail(error),
319 ),
320 ),
321 ),
322 ),
323 ).pipe(
324 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
325 );
326 }),
327 ),
328 };
329 }),
330 );