1import * as s3 from "@distilled.cloud/aws/s3";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import { createHash } from "node:crypto";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { initialCwd } from "../../Util/Node.ts";
12import type { Providers } from "../Providers.ts";
13import type { WebsiteTextEncoding } from "./shared.ts";
14
15export interface AssetFileOption {
16 /**
17 * Glob or globs of files to match.
18 */
19 files: string | string[];
20 /**
21 * Override `Content-Type` for matched files.
22 */
23 contentType?: string;
24 /**
25 * Override `Cache-Control` for matched files.
26 */
27 cacheControl?: string;
28 /**
29 * Optional glob or globs excluded from this rule.
30 */
31 ignore?: string | string[];
32}
33
34export interface AssetDeploymentProps {
35 /**
36 * Destination bucket.
37 */
38 bucket: {
39 bucketName: string;
40 };
41 /**
42 * Local directory to upload.
43 */
44 sourcePath: string;
45 /**
46 * Optional key prefix within the bucket.
47 */
48 prefix?: string;
49 /**
50 * Remove old files under the prefix that are not part of the current deploy.
51 * @default false
52 */
53 purge?: boolean;
54 /**
55 * Optional per-file overrides.
56 */
57 fileOptions?: AssetFileOption[];
58 /**
59 * Character encoding applied to inferred text-based asset content types.
60 * @default "utf-8"
61 */
62 textEncoding?: WebsiteTextEncoding;
63}
64
65export interface AssetDeployment extends Resource<
66 "AWS.Website.AssetDeployment",
67 AssetDeploymentProps,
68 {
69 /**
70 * Name of the destination bucket.
71 */
72 bucketName: string;
73 /**
74 * Normalized key prefix the files were uploaded under (empty string when
75 * no prefix was configured).
76 */
77 prefix: string;
78 /**
79 * Content hash of the uploaded file set — changes whenever any file body,
80 * content type, or cache-control changes. Useful as an invalidation token.
81 */
82 version: string;
83 /**
84 * Number of files uploaded in this deployment.
85 */
86 fileCount: number;
87 /**
88 * POSIX-relative paths of every uploaded file (before the `prefix` is
89 * applied), sorted. Downstream consumers derive routing manifests from
90 * this list (e.g. `StaticSite`'s CloudFront KV file manifest), so the
91 * manifest always reflects exactly what was uploaded.
92 */
93 files: string[];
94 },
95 never,
96 Providers
97> {}
98
99/**
100 * Upload a local directory into S3 with website-friendly defaults.
101 *
102 * `AssetDeployment` is a helper resource for website hosting. It uploads all
103 * files in a directory, infers content types, applies cache-control defaults
104 * (HTML is never cached; everything else is immutable), and can optionally
105 * purge stale files under a prefix. `StaticSite` and `SsrSite` use it
106 * internally — reach for it directly when you manage the bucket and CDN
107 * yourself.
108 * ### Deploying Files
109 * **Example:** Upload A Build Directory
110 * ```typescript
111 * const bucket = yield* AWS.S3.Bucket("SiteBucket", {});
112 *
113 * const files = yield* AssetDeployment("WebsiteFiles", {
114 * bucket,
115 * sourcePath: "./dist",
116 * prefix: "_assets",
117 * });
118 * ```
119 *
120 * **Example:** Purge Stale Files And Override Caching
121 * ```typescript
122 * const files = yield* AssetDeployment("WebsiteFiles", {
123 * bucket,
124 * sourcePath: "./dist",
125 * purge: true,
126 * fileOptions: [
127 * {
128 * files: "**\/*.json",
129 * cacheControl: "max-age=300,public",
130 * },
131 * ],
132 * });
133 * ```
134 *
135 * ### Invalidation
136 * **Example:** Invalidate CloudFront On Content Change
137 * ```typescript
138 * const files = yield* AssetDeployment("WebsiteFiles", {
139 * bucket,
140 * sourcePath: "./dist",
141 * });
142 *
143 * // `version` only changes when file contents change, so the
144 * // invalidation re-runs exactly when a new deploy ships new bytes.
145 * yield* AWS.CloudFront.Invalidation("Invalidation", {
146 * distributionId: distribution.distributionId,
147 * version: files.version,
148 * paths: ["/*"],
149 * });
150 * ```
151 *
152 * @resource
153 */
154export const AssetDeployment = Resource<AssetDeployment>(
155 "AWS.Website.AssetDeployment",
156);
157
158const defaultHtmlCacheControl = "max-age=0,no-cache,no-store,must-revalidate";
159const defaultAssetCacheControl = "max-age=31536000,public,immutable";
160
161export const AssetDeploymentProvider = () =>
162 Provider.effect(
163 AssetDeployment,
164 Effect.gen(function* () {
165 const reconcileSync = Effect.fn(function* (news: AssetDeploymentProps) {
166 const fs = yield* FileSystem.FileSystem;
167 const path = yield* Path.Path;
168 const bucketName = news.bucket.bucketName;
169 const prefix = normalizePrefix(news.prefix);
170 // Resolve against the process's INITIAL cwd, never the live cwd:
171 // Server persists build dirs relative to initialCwd, and a sibling
172 // framework build may transiently chdir this shared process while
173 // the upload walks the tree.
174 const root = path.resolve(initialCwd, news.sourcePath);
175 const files = yield* walkFiles(root);
176 const observed = yield* listObjects(
177 bucketName,
178 prefix ? `${prefix}/` : prefix,
179 );
180 const prepared = yield* Effect.all(
181 files.map((relativePath) =>
182 Effect.gen(function* () {
183 const body = yield* fs.readFile(path.join(root, relativePath));
184 const normalizedRelativePath = toPosix(relativePath);
185 const key = prefix
186 ? `${prefix}/${normalizedRelativePath}`
187 : normalizedRelativePath;
188 const options = getFileOptions(
189 normalizedRelativePath,
190 news.fileOptions,
191 news.textEncoding,
192 );
193 return {
194 relative: normalizedRelativePath,
195 key,
196 body,
197 contentType: options.contentType,
198 cacheControl: options.cacheControl,
199 };
200 }),
201 ),
202 { concurrency: "unbounded" },
203 );
204 const version = yield* Effect.sync(() => {
205 const hash = createHash("sha256");
206 for (const file of prepared) {
207 hash.update(file.relative);
208 hash.update(file.body);
209 hash.update(file.contentType);
210 hash.update(file.cacheControl);
211 }
212 return hash.digest("hex");
213 });
214 const desiredKeys = new Set(prepared.map((file) => file.key));
215 yield* Effect.all(
216 prepared.flatMap((file) => {
217 const expectedETag = createHash("md5")
218 .update(file.body)
219 .digest("hex");
220 const observedETag = observed.get(file.key)?.replace(/^"|"$/g, "");
221 if (observedETag === expectedETag) return [];
222 return [
223 s3.putObject({
224 Bucket: bucketName,
225 Key: file.key,
226 Body: file.body,
227 ContentType: file.contentType,
228 CacheControl: file.cacheControl,
229 }),
230 ];
231 }),
232 { concurrency: 16 },
233 );
234
235 if (news.purge ?? false) {
236 yield* deleteKeys(
237 bucketName,
238 [...observed.keys()].filter((key) => !desiredKeys.has(key)),
239 );
240 }
241
242 return {
243 bucketName,
244 prefix,
245 version,
246 fileCount: files.length,
247 files: prepared.map((file) => file.relative),
248 };
249 });
250
251 return {
252 // Non-listable: an AssetDeployment is an action (uploading a local
253 // directory into a bucket under a prefix), keyed by {bucketName,
254 // prefix}, not a standalone cloud resource. There is no AWS API that
255 // enumerates "asset deployments" — the uploaded objects are plain S3
256 // objects owned by their bucket — so there is nothing to enumerate.
257 list: () => Effect.succeed([]),
258 read: Effect.fn(function* ({ output }) {
259 return output;
260 }),
261 reconcile: Effect.fn(function* ({ news, session }) {
262 const output = yield* retryForBucketReadiness(reconcileSync(news));
263 yield* session.note(
264 `Uploaded ${output.fileCount} file(s) to s3://${output.bucketName}/${output.prefix}`,
265 );
266 return output;
267 }),
268 delete: Effect.fn(function* ({ olds, output }) {
269 if (!(olds.purge ?? false)) {
270 return;
271 }
272 const prefix = output.prefix ? `${output.prefix}/` : output.prefix;
273 yield* retryForBucketReadiness(
274 Effect.gen(function* () {
275 const existingKeys = yield* listKeys(output.bucketName, prefix);
276 yield* deleteKeys(output.bucketName, existingKeys);
277 }),
278 ).pipe(Effect.catchTag("NoSuchBucket", () => Effect.void));
279 }),
280 };
281 }),
282 );
283
284const normalizePrefix = (prefix: string | undefined) =>
285 prefix ? prefix.replace(/^\/+|\/+$/g, "") : "";
286
287const toPosix = (value: string) => value.replaceAll("\\", "/");
288
289const extname = (file: string) => {
290 const base = toPosix(file).split("/").pop() ?? file;
291 const dot = base.lastIndexOf(".");
292 return dot > 0 ? base.slice(dot).toLowerCase() : "";
293};
294
295const withCharset = (mimeType: string, textEncoding: WebsiteTextEncoding) =>
296 textEncoding === "none" ? mimeType : `${mimeType}; charset=${textEncoding}`;
297
298const inferContentType = (
299 file: string,
300 textEncoding: WebsiteTextEncoding = "utf-8",
301) => {
302 const ext = extname(file);
303 switch (ext) {
304 case ".html":
305 return withCharset("text/html", textEncoding);
306 case ".css":
307 return withCharset("text/css", textEncoding);
308 case ".js":
309 case ".mjs":
310 return withCharset("application/javascript", textEncoding);
311 case ".json":
312 return withCharset("application/json", textEncoding);
313 case ".svg":
314 return "image/svg+xml";
315 case ".png":
316 return "image/png";
317 case ".jpg":
318 case ".jpeg":
319 return "image/jpeg";
320 case ".gif":
321 return "image/gif";
322 case ".webp":
323 return "image/webp";
324 case ".ico":
325 return "image/x-icon";
326 case ".txt":
327 return withCharset("text/plain", textEncoding);
328 case ".xml":
329 return withCharset("application/xml", textEncoding);
330 case ".woff":
331 return "font/woff";
332 case ".woff2":
333 return "font/woff2";
334 default:
335 return "application/octet-stream";
336 }
337};
338
339const defaultCacheControlFor = (file: string) =>
340 extname(file) === ".html"
341 ? defaultHtmlCacheControl
342 : defaultAssetCacheControl;
343
344const escapeRegex = (value: string) =>
345 value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
346
347const globToRegExp = (glob: string) =>
348 new RegExp(
349 `^${escapeRegex(toPosix(glob))
350 .replace(/\\\*\\\*/g, ".*")
351 .replace(/\\\*/g, "[^/]*")
352 .replace(/\\\?/g, ".")}$`,
353 );
354
355const matchesAny = (file: string, globs: string | string[]) =>
356 (Array.isArray(globs) ? globs : [globs]).some((glob) =>
357 globToRegExp(glob).test(file),
358 );
359
360const getFileOptions = (
361 file: string,
362 options: AssetFileOption[] | undefined,
363 textEncoding: WebsiteTextEncoding | undefined,
364): {
365 contentType: string;
366 cacheControl: string;
367} => {
368 const matched = [...(options ?? [])]
369 .reverse()
370 .find(
371 (option) =>
372 matchesAny(file, option.files) &&
373 !(option.ignore && matchesAny(file, option.ignore)),
374 );
375
376 return {
377 contentType:
378 matched?.contentType ?? inferContentType(file, textEncoding ?? "utf-8"),
379 cacheControl: matched?.cacheControl ?? defaultCacheControlFor(file),
380 };
381};
382
383const walkFiles = Effect.fn(function* (root: string) {
384 const fs = yield* FileSystem.FileSystem;
385 const path = yield* Path.Path;
386 if (!(yield* fs.exists(root))) return [] as string[];
387 const names = yield* fs.readDirectory(root, { recursive: true });
388 const files = yield* Effect.all(
389 names.map((name) =>
390 Effect.gen(function* () {
391 const full = path.join(root, name);
392 const stat = yield* fs.stat(full);
393 return stat.type === "File" ? toPosix(name) : undefined;
394 }),
395 ),
396 { concurrency: "unbounded" },
397 );
398 return files
399 .filter((name): name is string => name !== undefined)
400 .sort((a, b) => a.localeCompare(b));
401});
402
403const listObjectPages = (bucketName: string, prefix: string) =>
404 s3.listObjectsV2
405 .pages({
406 Bucket: bucketName,
407 Prefix: prefix || undefined,
408 })
409 .pipe(Stream.flatMap((page) => Stream.fromIterable(page.Contents ?? [])));
410
411const listKeys = (bucketName: string, prefix: string) =>
412 listObjectPages(bucketName, prefix).pipe(
413 Stream.map((object) => object.Key),
414 Stream.filter((key): key is string => key !== undefined),
415 Stream.runCollect,
416 Effect.map((keys) => Array.from(keys)),
417 );
418
419/** List `{key -> ETag}` pairs under `prefix`. ETag from S3 is wrapped in
420 * quotes; for non-multipart uploads it is the hex MD5 of the object body. */
421const listObjects = (bucketName: string, prefix: string) =>
422 listObjectPages(bucketName, prefix).pipe(
423 Stream.runFold(
424 () => new Map<string, string>(),
425 (out, item) => {
426 if (item.Key && item.ETag) {
427 out.set(item.Key, item.ETag);
428 }
429 return out;
430 },
431 ),
432 );
433
434const deleteKeys = Effect.fn(function* (bucketName: string, keys: string[]) {
435 for (let i = 0; i < keys.length; i += 1000) {
436 const batch = keys.slice(i, i + 1000);
437 if (batch.length === 0) {
438 continue;
439 }
440 yield* s3.deleteObjects({
441 Bucket: bucketName,
442 Delete: {
443 Objects: batch.map((Key) => ({ Key })),
444 Quiet: true,
445 },
446 });
447 }
448});
449
450const isMissingBucket = (error: unknown) =>
451 (error as { _tag?: string })._tag === "NoSuchBucket";
452
453const retryForBucketReadiness = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
454 effect.pipe(
455 Effect.retry({
456 while: isMissingBucket,
457 schedule: Schedule.max([
458 Schedule.exponential("100 millis"),
459 Schedule.recurs(30),
460 ]).pipe(
461 Schedule.modifyDelay(({ duration }) =>
462 Effect.succeed(
463 Duration.isGreaterThan(duration, Duration.seconds(2))
464 ? Duration.seconds(2)
465 : duration,
466 ),
467 ),
468 ),
469 }),
470 );