| 1 | import * as s3 from "@distilled.cloud/aws/s3"; |
| 2 | import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | import * as Schedule from "effect/Schedule"; |
| 7 | import * as Stream from "effect/Stream"; |
| 8 | import { createHash } from "node:crypto"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource } from "../../Resource.ts"; |
| 11 | import { initialCwd } from "../../Util/Node.ts"; |
| 12 | import type { Providers } from "../Providers.ts"; |
| 13 | import type { WebsiteTextEncoding } from "./shared.ts"; |
| 14 | |
| 15 | export 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 | |
| 34 | export 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 | |
| 65 | export 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 | */ |
| 154 | export const AssetDeployment = Resource<AssetDeployment>( |
| 155 | "AWS.Website.AssetDeployment", |
| 156 | ); |
| 157 | |
| 158 | const defaultHtmlCacheControl = "max-age=0,no-cache,no-store,must-revalidate"; |
| 159 | const defaultAssetCacheControl = "max-age=31536000,public,immutable"; |
| 160 | |
| 161 | export 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 | |
| 284 | const normalizePrefix = (prefix: string | undefined) => |
| 285 | prefix ? prefix.replace(/^\/+|\/+$/g, "") : ""; |
| 286 | |
| 287 | const toPosix = (value: string) => value.replaceAll("\\", "/"); |
| 288 | |
| 289 | const 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 | |
| 295 | const withCharset = (mimeType: string, textEncoding: WebsiteTextEncoding) => |
| 296 | textEncoding === "none" ? mimeType : `${mimeType}; charset=${textEncoding}`; |
| 297 | |
| 298 | const 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 | |
| 339 | const defaultCacheControlFor = (file: string) => |
| 340 | extname(file) === ".html" |
| 341 | ? defaultHtmlCacheControl |
| 342 | : defaultAssetCacheControl; |
| 343 | |
| 344 | const escapeRegex = (value: string) => |
| 345 | value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); |
| 346 | |
| 347 | const globToRegExp = (glob: string) => |
| 348 | new RegExp( |
| 349 | `^${escapeRegex(toPosix(glob)) |
| 350 | .replace(/\\\*\\\*/g, ".*") |
| 351 | .replace(/\\\*/g, "[^/]*") |
| 352 | .replace(/\\\?/g, ".")}$`, |
| 353 | ); |
| 354 | |
| 355 | const matchesAny = (file: string, globs: string | string[]) => |
| 356 | (Array.isArray(globs) ? globs : [globs]).some((glob) => |
| 357 | globToRegExp(glob).test(file), |
| 358 | ); |
| 359 | |
| 360 | const 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 | |
| 383 | const 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 | |
| 403 | const 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 | |
| 411 | const 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. */ |
| 421 | const 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 | |
| 434 | const 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 | |
| 450 | const isMissingBucket = (error: unknown) => |
| 451 | (error as { _tag?: string })._tag === "NoSuchBucket"; |
| 452 | |
| 453 | const 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 | ); |