1import type { Credentials } from "@distilled.cloud/aws/Credentials";
2import { Region } from "@distilled.cloud/aws/Region";
3import * as s3 from "@distilled.cloud/aws/s3";
4import * as Context from "effect/Context";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Schedule from "effect/Schedule";
9import * as Stream from "effect/Stream";
10import type { HttpClient } from "effect/http/HttpClient";
11import { AWSEnvironment } from "./Environment.ts";
12
13/**
14 * Tag key used to identify the alchemy assets bucket.
15 */
16export const ASSETS_BUCKET_TAG = "alchemy::assets-bucket";
17
18/**
19 * Error type for Assets service operations.
20 */
21export type AssetsError =
22 | {
23 readonly _tag: "AssetsUploadError";
24 readonly message: string;
25 readonly cause?: unknown;
26 }
27 | {
28 readonly _tag: "AssetsCheckError";
29 readonly message: string;
30 readonly cause?: unknown;
31 };
32
33/**
34 * Requirements for Assets operations (S3 operations need these).
35 */
36export type AssetsRequirements =
37 | Region
38 | Credentials
39 | HttpClient
40 | AWSEnvironment;
41
42export class Assets extends Context.Service<
43 Assets,
44 {
45 /**
46 * The name of the assets bucket.
47 */
48 readonly bucketName: Effect.Effect<string, never, AssetsRequirements>;
49
50 /**
51 * Upload an asset to the assets bucket.
52 * Uses content-addressed storage: `lambda/{hash}.zip`
53 *
54 * @param hash - The content hash of the asset
55 * @param content - The asset content (zip file)
56 * @returns The S3 key where the asset was uploaded
57 */
58 readonly uploadAsset: (
59 hash: string,
60 content: Uint8Array,
61 ) => Effect.Effect<string, AssetsError, AssetsRequirements>;
62
63 /**
64 * Check if an asset already exists in the assets bucket.
65 *
66 * @param hash - The content hash to check
67 * @returns true if the asset exists
68 */
69 readonly hasAsset: (
70 hash: string,
71 ) => Effect.Effect<boolean, AssetsError, AssetsRequirements>;
72 }
73>()("AWS::Assets") {
74 static BucketName = Assets.use((assets) => assets.bucketName);
75}
76
77/**
78 * Layer that provides the Assets service.
79 * Looks up the assets bucket on initialization.
80 * If the bucket doesn't exist, the layer will fail - use `assetsLayerWithFallback` for graceful fallback.
81 */
82export const AssetsLive = Layer.effect(
83 Assets,
84 Effect.gen(function* () {
85 const bucketName = yield* lookupAssetsBucket.pipe(
86 Effect.flatMap(
87 Option.match({
88 onNone: () =>
89 Effect.gen(function* () {
90 const environment = yield* AWSEnvironment.current;
91 if (!environment.endpoint) {
92 return yield* Effect.die(
93 new Error(
94 "Assets bucket not found. Run 'alchemy provider aws bootstrap' to create it.",
95 ),
96 );
97 }
98 // Local emulator (floci/LocalStack): bootstrap transparently —
99 // the bucket is free, instant, and disposable with the emulator.
100 return yield* createAssetsBucket;
101 }),
102 onSome: (bucketName) => Effect.succeed(bucketName),
103 }),
104 ),
105 Effect.orDie,
106 Effect.cached,
107 );
108
109 const getLambdaAssetKey = (hash: string) => `lambda/${hash}.zip`;
110
111 return {
112 bucketName,
113 uploadAsset: (hash: string, content: Uint8Array) => {
114 const key = getLambdaAssetKey(hash);
115
116 return Effect.gen(function* () {
117 // Check if asset already exists
118 const exists = yield* s3
119 .headObject({ Bucket: yield* bucketName, Key: key })
120 .pipe(
121 Effect.map(() => true),
122 Effect.catchTag("NotFound", () => Effect.succeed(false)),
123 );
124
125 if (exists) {
126 yield* Effect.logDebug(
127 `Asset already exists: s3://${yield* bucketName}/${key}`,
128 );
129 return key;
130 }
131
132 // Upload the asset
133 yield* s3.putObject({
134 Bucket: yield* bucketName,
135 Key: key,
136 Body: content,
137 ContentType: "application/zip",
138 });
139
140 yield* Effect.logDebug(
141 `Uploaded asset: s3://${yield* bucketName}/${key}`,
142 );
143 return key;
144 }).pipe(
145 Effect.mapError((err): AssetsError => ({
146 _tag: "AssetsUploadError",
147 message: `Failed to upload asset ${key}`,
148 cause: err,
149 })),
150 );
151 },
152 hasAsset: Effect.fn(function* (hash: string) {
153 const key = getLambdaAssetKey(hash);
154
155 return yield* s3
156 .headObject({ Bucket: yield* bucketName, Key: key })
157 .pipe(
158 Effect.map(() => true),
159 Effect.catchTag("NotFound", () => Effect.succeed(false)),
160 Effect.mapError((err): AssetsError => ({
161 _tag: "AssetsCheckError",
162 message: `Failed to check asset ${key}`,
163 cause: err,
164 })),
165 );
166 }),
167 };
168 }),
169);
170
171export const lookupAssetsBuckets = Effect.gen(function* () {
172 const { region } = yield* AWSEnvironment.current;
173
174 return yield* s3.listBuckets
175 .pages({
176 Prefix: "alchemy-assets-",
177 BucketRegion: region,
178 })
179 .pipe(
180 Stream.flatMap((page) => Stream.fromIterable(page.Buckets ?? [])),
181 Stream.filterEffect((bucket) =>
182 bucket.Name === undefined
183 ? Effect.succeed(false)
184 : getBucketTags(bucket.Name).pipe(
185 Effect.map((tags) => hasAssetsBucketTag(tags)),
186 Effect.catch(() => Effect.succeed(false)),
187 ),
188 ),
189 Stream.map((bucket) => bucket.Name!),
190 Stream.runCollect,
191 );
192});
193
194export const lookupAssetsBucket = Effect.gen(function* () {
195 const matchingBuckets = yield* lookupAssetsBuckets;
196 for (const bucketName of matchingBuckets) {
197 return Option.some(bucketName);
198 }
199 return Option.none<string>();
200});
201
202const hasAssetsBucketTag = (tags: Array<{ Key?: string; Value?: string }>) =>
203 tags.some((tag) => tag.Key === ASSETS_BUCKET_TAG && tag.Value === "true");
204
205/**
206 * Build an account-regional namespace bucket name.
207 *
208 * Account-regional buckets must follow the naming convention:
209 * `<prefix>-<accountId>-<region>-an`
210 *
211 * @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/gpbucketnamespaces.html#account-regional-gp-buckets
212 */
213export const createAssetsBucketName = (accountId: string, region: string) =>
214 `alchemy-assets-${accountId}-${region}-an`.toLowerCase();
215
216const getBucketTags = (bucketName: string) =>
217 s3.getBucketTagging({ Bucket: bucketName }).pipe(
218 Effect.map((response) => response.TagSet ?? []),
219 Effect.catchTag("NoSuchTagSet", () =>
220 Effect.succeed<Array<{ Key?: string; Value?: string }>>([]),
221 ),
222 );
223
224/**
225 * Create the tagged assets bucket for the current account+region and wait for
226 * it to be addressable. Idempotent — used by `alchemy provider aws bootstrap` and by
227 * the transparent local-emulator bootstrap in {@link AssetsLive}.
228 */
229export const createAssetsBucket = Effect.gen(function* () {
230 const { accountId, region } = yield* AWSEnvironment.current;
231 const bucketName = createAssetsBucketName(accountId, region);
232 yield* s3
233 .createBucket({
234 Bucket: bucketName,
235 BucketNamespace: "account-regional",
236 CreateBucketConfiguration: {
237 Tags: [{ Key: ASSETS_BUCKET_TAG, Value: "true" }],
238 ...(region === "us-east-1"
239 ? {}
240 : {
241 LocationConstraint: region as s3.BucketLocationConstraint,
242 }),
243 },
244 })
245 .pipe(
246 Effect.catchTag("BucketAlreadyOwnedByYou", () => Effect.void),
247 Effect.retry({
248 while: (e): boolean =>
249 e._tag === "OperationAborted" || e._tag === "ServiceUnavailable",
250 schedule: Schedule.exponential(100),
251 }),
252 );
253
254 yield* s3.headBucket({ Bucket: bucketName }).pipe(
255 Effect.retry({
256 schedule: Schedule.max([Schedule.exponential(100), Schedule.recurs(10)]),
257 }),
258 );
259
260 yield* Effect.logInfo(`Created assets bucket: ${bucketName}`);
261 return bucketName;
262});
263
264export const ensureAssetsBucketTags = Effect.fn(function* (bucketName: string) {
265 const existingTags = yield* getBucketTags(bucketName);
266 const tagSet = [
267 ...existingTags.filter((tag) => tag.Key !== ASSETS_BUCKET_TAG),
268 { Key: ASSETS_BUCKET_TAG, Value: "true" },
269 ];
270
271 yield* s3.putBucketTagging({
272 Bucket: bucketName,
273 Tagging: {
274 TagSet: tagSet.map((tag) => ({
275 Key: tag.Key!,
276 Value: tag.Value!,
277 })),
278 },
279 });
280});