1/**
2 * vinext data-cache adapter backed by S3. Default export is the factory
3 * `virtual:vinext-cache-adapters` calls as `({ env, options }) => CacheHandler`.
4 *
5 * Uses distilled `@distilled.cloud/aws/s3` — the same client
6 * `AWS.S3.GetObject` / `PutObject` / `DeleteObject` wrap. Credentials come
7 * from the Lambda environment (`Credentials.fromEnv`), matching the
8 * Function bootstrap. Prerender artifacts are seeded into S3 in the
9 * background so cold starts HIT.
10 */
11import * as Credentials from "@distilled.cloud/aws/Credentials";
12import * as S3 from "@distilled.cloud/aws/s3";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Stream from "effect/Stream";
16import { layer as fetchHttpClientLayer } from "effect/http/FetchHttpClient";
17import type * as HttpClient from "effect/http/HttpClient";
18import { makeDataCacheHandler, type DataCacheStore } from "./handler.ts";
19import { readEnvString } from "./shared.ts";
20import { seedStoreFromPrerenderLogged } from "./seed.ts";
21
22export interface S3AdapterOptions extends Record<string, unknown> {
23 /** Env var holding the bucket name. @default "CACHE_BUCKET_NAME" */
24 readonly bucketEnv?: string;
25 /** Key prefix inside the bucket. @default "vinext-cache/" */
26 readonly prefix?: string;
27 readonly appPrefix?: string;
28 readonly ttlSeconds?: number;
29 readonly tagCacheTtlMs?: number;
30}
31
32const DEFAULT_BUCKET_ENV = "CACHE_BUCKET_NAME";
33const DEFAULT_PREFIX = "vinext-cache/";
34
35const awsLayer = Layer.mergeAll(Credentials.fromEnv(), fetchHttpClientLayer);
36
37const runAws = <A, E>(
38 effect: Effect.Effect<A, E, Credentials.Credentials | HttpClient.HttpClient>,
39) => Effect.runPromise(effect.pipe(Effect.provide(awsLayer)));
40
41const s3Store = (bucket: string, prefix: string): DataCacheStore => {
42 const objectKey = (key: string) => `${prefix}${key}`;
43 return {
44 async getText(key) {
45 return runAws(
46 S3.getObject({
47 Bucket: bucket,
48 Key: objectKey(key),
49 }).pipe(
50 Effect.flatMap((result) =>
51 result.Body === undefined
52 ? Effect.succeed(undefined)
53 : Stream.mkString(Stream.decodeText(result.Body)),
54 ),
55 Effect.catchTag("NoSuchKey", () => Effect.succeed(undefined)),
56 ),
57 );
58 },
59 async putText(key, value) {
60 await runAws(
61 S3.putObject({
62 Bucket: bucket,
63 Key: objectKey(key),
64 Body: value,
65 ContentType: "application/json",
66 }),
67 );
68 },
69 async delete(key) {
70 await runAws(
71 S3.deleteObject({
72 Bucket: bucket,
73 Key: objectKey(key),
74 }).pipe(Effect.catchTag("NoSuchKey", () => Effect.void)),
75 );
76 },
77 };
78};
79
80const createS3DataCacheAdapter = ({
81 env,
82 options,
83}: {
84 env?: Record<string, unknown>;
85 options?: S3AdapterOptions;
86}) => {
87 const bucketEnv = options?.bucketEnv ?? DEFAULT_BUCKET_ENV;
88 const bucket = readEnvString(env, bucketEnv);
89 if (bucket === undefined) {
90 throw new Error(
91 `[vinext] The S3 data cache adapter requires \`${bucketEnv}\` in the process environment.`,
92 );
93 }
94 const store = s3Store(bucket, options?.prefix ?? DEFAULT_PREFIX);
95 let handler: ReturnType<typeof makeDataCacheHandler> | undefined;
96 const ready = seedStoreFromPrerenderLogged(store, "S3").then(() => {
97 handler = makeDataCacheHandler(store, options);
98 return handler;
99 });
100 return {
101 async get(key: string, ctx?: Record<string, unknown>) {
102 return (handler ?? (await ready)).get(key, ctx);
103 },
104 async set(
105 key: string,
106 data: Record<string, unknown> | null,
107 ctx?: Record<string, unknown>,
108 ) {
109 return (handler ?? (await ready)).set(key, data, ctx);
110 },
111 async revalidateTag(tags: string | Array<string>) {
112 return (handler ?? (await ready)).revalidateTag(tags);
113 },
114 resetRequestCache() {
115 handler?.resetRequestCache();
116 },
117 };
118};
119
120export default createS3DataCacheAdapter;