| 1 | import * as S3 from "@distilled.cloud/aws/s3"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Binding from "../Binding.ts"; |
| 5 | import type { RuntimeContext } from "../RuntimeContext.ts"; |
| 6 | import type { Bucket } from "./Bucket.ts"; |
| 7 | import { makeBucketBinding } from "./BucketBinding.ts"; |
| 8 | import type { |
| 9 | BucketCredentials, |
| 10 | BucketError, |
| 11 | BucketObject, |
| 12 | BucketObjectBody, |
| 13 | GetOptions, |
| 14 | ListOptions, |
| 15 | ListResult, |
| 16 | PresignGetOptions, |
| 17 | } from "./BucketTypes.ts"; |
| 18 | import { |
| 19 | makeBucketAccess, |
| 20 | objectBodyFrom, |
| 21 | objectFrom, |
| 22 | objectFromListEntry, |
| 23 | rangeHeader, |
| 24 | toBucketError, |
| 25 | type BucketAccess, |
| 26 | } from "./Internal/BucketClient.ts"; |
| 27 | |
| 28 | export interface ReadBucket extends Binding.Service< |
| 29 | ReadBucket, |
| 30 | "Prisma.ReadBucket", |
| 31 | (bucket: Bucket) => Effect.Effect<ReadBucketClient> |
| 32 | > {} |
| 33 | |
| 34 | /** |
| 35 | * Read-only client for a Prisma Object Store bucket. It deliberately exposes |
| 36 | * no write operations — see the role caveat on {@link ReadBucketBinding}. |
| 37 | */ |
| 38 | export interface ReadBucketClient { |
| 39 | /** |
| 40 | * Read an object's metadata without downloading it. Resolves `null` when the |
| 41 | * key does not exist. |
| 42 | */ |
| 43 | head( |
| 44 | key: string, |
| 45 | ): Effect.Effect<BucketObject | null, BucketError, RuntimeContext>; |
| 46 | /** |
| 47 | * Read an object and its body. Resolves `null` when the key does not exist. |
| 48 | */ |
| 49 | get( |
| 50 | key: string, |
| 51 | options?: GetOptions, |
| 52 | ): Effect.Effect<BucketObjectBody | null, BucketError, RuntimeContext>; |
| 53 | /** |
| 54 | * List one page of objects in the bucket. |
| 55 | */ |
| 56 | list( |
| 57 | options?: ListOptions, |
| 58 | ): Effect.Effect<ListResult, BucketError, RuntimeContext>; |
| 59 | /** |
| 60 | * Mint a presigned download URL, so a browser can read the object without |
| 61 | * credentials. Pure client-side SigV4 — no request is made to the store. |
| 62 | */ |
| 63 | presignGet( |
| 64 | key: string, |
| 65 | options?: PresignGetOptions, |
| 66 | ): Effect.Effect<string, BucketError, RuntimeContext>; |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Bind a Prisma Object Store {@link Bucket} to a Prisma Compute app, AWS |
| 71 | * Lambda Function, or Cloudflare Worker with read-only access, and obtain the |
| 72 | * typed runtime client. |
| 73 | * |
| 74 | * Binding creates a read-scoped `Prisma.BucketAccessKey` for the bucket and carries |
| 75 | * its S3 credentials into the host environment, so the caller never handles a |
| 76 | * credential themselves. |
| 77 | * |
| 78 | * Provide {@link ReadBucketBinding} on the host implementation. |
| 79 | * |
| 80 | * Prisma bucket keys carry one of two coarse roles, `read` and `read_write`. |
| 81 | * This binding mints the `read` one, so the credential it puts in the host |
| 82 | * environment cannot write, and {@link ReadBucketClient} exposes no write |
| 83 | * operations either. |
| 84 | * |
| 85 | * ### Binding a Bucket |
| 86 | * **Example:** Read objects from Prisma Compute |
| 87 | * ```typescript |
| 88 | * export default Prisma.Compute( |
| 89 | * "api", |
| 90 | * { project, main: import.meta.filename }, |
| 91 | * Effect.gen(function* () { |
| 92 | * const uploads = yield* Prisma.ReadBucket(bucket); |
| 93 | * |
| 94 | * return { |
| 95 | * fetch: Effect.gen(function* () { |
| 96 | * const object = yield* uploads.get("reports/2026.json"); |
| 97 | * return yield* HttpServerResponse.json( |
| 98 | * object === null ? null : yield* object.json(), |
| 99 | * ); |
| 100 | * }), |
| 101 | * }; |
| 102 | * }).pipe(Effect.provide(Prisma.ReadBucketBinding)), |
| 103 | * ); |
| 104 | * ``` |
| 105 | * |
| 106 | * @binding |
| 107 | * @product Bucket |
| 108 | */ |
| 109 | export const ReadBucket = Binding.Service<ReadBucket>("Prisma.ReadBucket"); |
| 110 | |
| 111 | /** |
| 112 | * Build the read operations over an already-resolved transport. Shared with |
| 113 | * {@link ReadWriteBucket} so both levels run the same code. |
| 114 | */ |
| 115 | export const readBucketOperations = ( |
| 116 | access: BucketAccess, |
| 117 | ): ReadBucketClient => ({ |
| 118 | head: (key: string) => |
| 119 | access.bucketName.pipe( |
| 120 | Effect.flatMap((Bucket) => |
| 121 | access.authorize(S3.headObject({ Bucket, Key: key })), |
| 122 | ), |
| 123 | Effect.map((response) => objectFrom(key, response)), |
| 124 | // A missing key is absence, not a failure — mirror the native bucket |
| 125 | // clients that resolve `null`. |
| 126 | Effect.catchTag("NotFound", () => Effect.succeed(null)), |
| 127 | Effect.mapError(toBucketError), |
| 128 | ), |
| 129 | get: (key: string, options?: GetOptions) => |
| 130 | access.bucketName.pipe( |
| 131 | Effect.flatMap((Bucket) => |
| 132 | access.authorize( |
| 133 | S3.getObject({ |
| 134 | Bucket, |
| 135 | Key: key, |
| 136 | Range: rangeHeader(options?.range), |
| 137 | }), |
| 138 | ), |
| 139 | ), |
| 140 | Effect.map((response) => objectBodyFrom(key, response)), |
| 141 | Effect.catchTag("NoSuchKey", () => Effect.succeed(null)), |
| 142 | Effect.mapError(toBucketError), |
| 143 | ), |
| 144 | list: (options?: ListOptions) => |
| 145 | access.bucketName.pipe( |
| 146 | Effect.flatMap((Bucket) => |
| 147 | access.authorize( |
| 148 | S3.listObjectsV2({ |
| 149 | Bucket, |
| 150 | Prefix: options?.prefix, |
| 151 | Delimiter: options?.delimiter, |
| 152 | ContinuationToken: options?.cursor, |
| 153 | MaxKeys: options?.limit, |
| 154 | StartAfter: options?.startAfter, |
| 155 | }), |
| 156 | ), |
| 157 | ), |
| 158 | Effect.map((response): ListResult => { |
| 159 | const objects = (response.Contents ?? []).map(objectFromListEntry); |
| 160 | const delimitedPrefixes = (response.CommonPrefixes ?? []).flatMap( |
| 161 | (prefix) => (prefix.Prefix === undefined ? [] : [prefix.Prefix]), |
| 162 | ); |
| 163 | return response.IsTruncated && response.NextContinuationToken |
| 164 | ? { |
| 165 | objects, |
| 166 | delimitedPrefixes, |
| 167 | truncated: true, |
| 168 | cursor: response.NextContinuationToken, |
| 169 | } |
| 170 | : { objects, delimitedPrefixes, truncated: false }; |
| 171 | }), |
| 172 | Effect.mapError(toBucketError), |
| 173 | ), |
| 174 | presignGet: (key: string, options?: PresignGetOptions) => |
| 175 | access.presign({ |
| 176 | method: "GET", |
| 177 | key, |
| 178 | expiresIn: options?.expiresIn, |
| 179 | responseContentType: options?.contentType, |
| 180 | }), |
| 181 | }); |
| 182 | |
| 183 | /** |
| 184 | * Build a read-only bucket client from a bound bucket key's credentials. |
| 185 | */ |
| 186 | export const makeReadBucketClient = ( |
| 187 | credentials: BucketCredentials, |
| 188 | ): ReadBucketClient => readBucketOperations(makeBucketAccess(credentials)); |
| 189 | |
| 190 | /** |
| 191 | * Implementation layer for {@link ReadBucket}. Provide it on the host |
| 192 | * Function/Worker Effect: |
| 193 | * |
| 194 | * ```typescript |
| 195 | * Effect.gen(function* () { |
| 196 | * const uploads = yield* Prisma.ReadBucket(bucket); |
| 197 | * // ... |
| 198 | * }).pipe(Effect.provide(Prisma.ReadBucketBinding)) |
| 199 | * ``` |
| 200 | * |
| 201 | * Prisma bucket keys have only `read` and `read_write` roles, and this binding |
| 202 | * mints the `read` one, so the credential it carries into the host cannot |
| 203 | * write. |
| 204 | */ |
| 205 | export const ReadBucketBinding = Layer.effect( |
| 206 | ReadBucket, |
| 207 | makeBucketBinding({ |
| 208 | capability: "Read", |
| 209 | role: "read", |
| 210 | makeClient: makeReadBucketClient, |
| 211 | }), |
| 212 | ); |