| 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 | */ |
| 11 | import * as Credentials from "@distilled.cloud/aws/Credentials"; |
| 12 | import * as S3 from "@distilled.cloud/aws/s3"; |
| 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as Stream from "effect/Stream"; |
| 16 | import { layer as fetchHttpClientLayer } from "effect/http/FetchHttpClient"; |
| 17 | import type * as HttpClient from "effect/http/HttpClient"; |
| 18 | import { makeDataCacheHandler, type DataCacheStore } from "./handler.ts"; |
| 19 | import { readEnvString } from "./shared.ts"; |
| 20 | import { seedStoreFromPrerenderLogged } from "./seed.ts"; |
| 21 | |
| 22 | export 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 | |
| 32 | const DEFAULT_BUCKET_ENV = "CACHE_BUCKET_NAME"; |
| 33 | const DEFAULT_PREFIX = "vinext-cache/"; |
| 34 | |
| 35 | const awsLayer = Layer.mergeAll(Credentials.fromEnv(), fetchHttpClientLayer); |
| 36 | |
| 37 | const runAws = <A, E>( |
| 38 | effect: Effect.Effect<A, E, Credentials.Credentials | HttpClient.HttpClient>, |
| 39 | ) => Effect.runPromise(effect.pipe(Effect.provide(awsLayer))); |
| 40 | |
| 41 | const 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 | |
| 80 | const 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 | |
| 120 | export default createS3DataCacheAdapter; |