1import { Credentials, fromCredentials } from "@distilled.cloud/aws/Credentials";
2import * as AwsEndpoint from "@distilled.cloud/aws/Endpoint";
3import type { RegionName } from "@distilled.cloud/aws/Region";
4import * as s3 from "@distilled.cloud/aws/s3";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as FileSystem from "effect/FileSystem";
8import * as Path from "effect/Path";
9import * as Redacted from "effect/Redacted";
10import * as Stream from "effect/Stream";
11import * as FetchHttpClient from "effect/http/FetchHttpClient";
12import type * as HttpClient from "effect/http/HttpClient";
13import { createHash } from "node:crypto";
14import * as Provider from "../../Provider.ts";
15import { Resource } from "../../Resource.ts";
16import { initialCwd } from "../../Util/Node.ts";
17import { cacheControlOf, contentTypeOf } from "../../Website/assets.ts";
18import type { Bucket } from "../Bucket.ts";
19import { TigrisCredentialsMissing } from "../Errors.ts";
20import type { Providers } from "../Providers.ts";
21
22const s3Concurrency = 16;
23
24/**
25 * Unwrap Tigris {@link Bucket} attrs for the S3 client. Credentials and
26 * the endpoint are `Redacted<string>`; names are plain strings. This is
27 * Fly Bucket plumbing, not website MIME — it stays here because this
28 * resource is the only Website helper that speaks Tigris.
29 */
30const asPlain = (value: unknown): string | undefined => {
31 if (typeof value === "string" && value.length > 0) return value;
32 if (Redacted.isRedacted(value)) {
33 const inner = Redacted.value(value);
34 return typeof inner === "string" && inner.length > 0 ? inner : undefined;
35 }
36 return undefined;
37};
38
39export interface AssetDeploymentProps {
40 /**
41 * Destination Tigris bucket (a {@link Bucket} resource).
42 */
43 bucket: Bucket;
44 /**
45 * Local directory to upload (framework `clientDirectory`).
46 */
47 sourcePath: string;
48 /**
49 * Optional key prefix within the bucket.
50 */
51 prefix?: string;
52 /**
53 * Delete observed keys under the prefix that are not in this deploy.
54 * @default true
55 */
56 purge?: boolean;
57}
58
59export interface AssetDeployment extends Resource<
60 "Fly.Website.AssetDeployment",
61 AssetDeploymentProps,
62 {
63 bucketName: string;
64 prefix: string;
65 version: string;
66 fileCount: number;
67 files: string[];
68 },
69 never,
70 Providers
71> {}
72
73/**
74 * Upload a local directory into a public Tigris bucket for Fly Website
75 * statics. HTML is never cached; everything else is immutable.
76 *
77 * @resource
78 */
79export const AssetDeployment = Resource<AssetDeployment>(
80 "Fly.Website.AssetDeployment",
81);
82
83const normalizePrefix = (prefix: string | undefined) =>
84 prefix ? prefix.replace(/^\/+|\/+$/g, "") : "";
85
86const scopeOf = (bucket: Bucket) =>
87 Effect.gen(function* () {
88 const bucketName = asPlain(bucket.bucketName) ?? asPlain(bucket.name);
89 const accessKeyId = asPlain(bucket.accessKeyId);
90 const secretAccessKey = asPlain(bucket.secretAccessKey);
91 const endpoint = asPlain(bucket.endpoint);
92 const region = (asPlain(bucket.region) ?? "auto") as RegionName;
93 if (
94 bucketName === undefined ||
95 accessKeyId === undefined ||
96 secretAccessKey === undefined ||
97 endpoint === undefined
98 ) {
99 return yield* new TigrisCredentialsMissing({
100 name: bucketName ?? bucket.LogicalId,
101 });
102 }
103 return { bucketName, accessKeyId, secretAccessKey, endpoint, region };
104 });
105
106const withTigris = <A, E>(
107 scope: {
108 accessKeyId: string;
109 secretAccessKey: string;
110 endpoint: string;
111 region: RegionName;
112 },
113 operation: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>,
114) =>
115 operation.pipe(
116 Effect.provide(
117 Layer.mergeAll(
118 fromCredentials(
119 {
120 accessKeyId: scope.accessKeyId,
121 secretAccessKey: scope.secretAccessKey,
122 },
123 scope.region,
124 ),
125 AwsEndpoint.of(scope.endpoint),
126 FetchHttpClient.layer,
127 ),
128 ),
129 );
130
131const walkFiles = Effect.fn(function* (root: string) {
132 const fs = yield* FileSystem.FileSystem;
133 const path = yield* Path.Path;
134 if (!(yield* fs.exists(root))) return [] as string[];
135 const names = yield* fs.readDirectory(root, { recursive: true });
136 const files = yield* Effect.all(
137 names.map((name) =>
138 Effect.gen(function* () {
139 const full = path.join(root, name);
140 const stat = yield* fs.stat(full);
141 return stat.type === "File" ? name.replaceAll("\\", "/") : undefined;
142 }),
143 ),
144 { concurrency: "unbounded" },
145 );
146 return files
147 .filter((name): name is string => name !== undefined)
148 .sort((a, b) => a.localeCompare(b));
149});
150
151const listObserved = (
152 scope: {
153 bucketName: string;
154 accessKeyId: string;
155 secretAccessKey: string;
156 endpoint: string;
157 region: RegionName;
158 },
159 prefix: string,
160) =>
161 withTigris(
162 scope,
163 s3.listObjectsV2
164 .pages({
165 Bucket: scope.bucketName,
166 Prefix: prefix.length > 0 ? `${prefix}/` : undefined,
167 })
168 .pipe(
169 Stream.flatMap((page) => Stream.fromIterable(page.Contents ?? [])),
170 Stream.runFold(
171 () => new Map<string, string | undefined>(),
172 (observed, object) => {
173 if (object.Key !== undefined) {
174 observed.set(object.Key, object.ETag);
175 }
176 return observed;
177 },
178 ),
179 ),
180 );
181
182const deleteObject = (
183 scope: {
184 bucketName: string;
185 accessKeyId: string;
186 secretAccessKey: string;
187 endpoint: string;
188 region: RegionName;
189 },
190 key: string,
191) =>
192 withTigris(
193 scope,
194 s3.deleteObject({
195 Bucket: scope.bucketName,
196 Key: key,
197 }),
198 ).pipe(Effect.catchTag("NoSuchKey", () => Effect.void));
199
200export const AssetDeploymentProvider = () =>
201 Provider.effect(
202 AssetDeployment,
203 Effect.succeed({
204 list: () => Effect.succeed([]),
205 read: Effect.fn(function* ({ output }) {
206 return output;
207 }),
208 reconcile: Effect.fn(function* ({ news, session }) {
209 const fs = yield* FileSystem.FileSystem;
210 const path = yield* Path.Path;
211 const scope = yield* scopeOf(news.bucket);
212 const prefix = normalizePrefix(news.prefix);
213 const root = path.isAbsolute(news.sourcePath)
214 ? news.sourcePath
215 : path.resolve(initialCwd, news.sourcePath);
216 const files = yield* walkFiles(root);
217 const observed = yield* listObserved(scope, prefix);
218 const prepared = yield* Effect.all(
219 files.map((relative) =>
220 Effect.gen(function* () {
221 const body = yield* fs.readFile(path.join(root, relative));
222 const key =
223 prefix.length > 0 ? `${prefix}/${relative}` : relative;
224 const contentType = contentTypeOf(relative);
225 const cacheControl = cacheControlOf(relative);
226 return { relative, key, body, contentType, cacheControl };
227 }),
228 ),
229 { concurrency: "unbounded" },
230 );
231 const version = yield* Effect.sync(() => {
232 const hash = createHash("sha256");
233 for (const file of prepared) {
234 hash.update(file.relative);
235 hash.update(file.body);
236 hash.update(file.contentType);
237 hash.update(file.cacheControl);
238 }
239 return hash.digest("hex");
240 });
241 const desired = new Set(prepared.map((file) => file.key));
242 yield* Effect.all(
243 prepared.flatMap((file) => {
244 const expected = createHash("md5").update(file.body).digest("hex");
245 const etag = observed.get(file.key)?.replace(/^"|"$/g, "");
246 if (etag === expected) return [];
247 return [
248 withTigris(
249 scope,
250 s3.putObject({
251 Bucket: scope.bucketName,
252 Key: file.key,
253 Body: file.body,
254 ContentType: file.contentType,
255 CacheControl: file.cacheControl,
256 }),
257 ),
258 ];
259 }),
260 { concurrency: s3Concurrency },
261 );
262
263 if (news.purge ?? true) {
264 yield* Effect.all(
265 [...observed.keys()]
266 .filter((key) => !desired.has(key))
267 .map((key) => deleteObject(scope, key)),
268 { concurrency: s3Concurrency },
269 );
270 }
271
272 const output = {
273 bucketName: scope.bucketName,
274 prefix,
275 version,
276 fileCount: files.length,
277 files: prepared.map((file) => file.relative),
278 };
279 yield* session.note(
280 `Uploaded ${output.fileCount} file(s) to tigris://${output.bucketName}/${output.prefix}`,
281 );
282 return output;
283 }),
284 delete: Effect.fn(
285 function* ({ olds, output }) {
286 if (!(olds.purge ?? true)) return;
287 const scope = yield* scopeOf(olds.bucket);
288 const observed = yield* listObserved(scope, output.prefix);
289 yield* Effect.all(
290 [...observed.keys()].map((key) => deleteObject(scope, key)),
291 { concurrency: s3Concurrency },
292 );
293 },
294 Effect.catchTag("NoSuchBucket", () => Effect.void),
295 ),
296 }),
297 );