| 1 | import { Credentials, fromCredentials } from "@distilled.cloud/aws/Credentials"; |
| 2 | import * as AwsEndpoint from "@distilled.cloud/aws/Endpoint"; |
| 3 | import type { RegionName } from "@distilled.cloud/aws/Region"; |
| 4 | import * as s3 from "@distilled.cloud/aws/s3"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Path from "effect/Path"; |
| 9 | import * as Redacted from "effect/Redacted"; |
| 10 | import * as Stream from "effect/Stream"; |
| 11 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 12 | import type * as HttpClient from "effect/http/HttpClient"; |
| 13 | import { createHash } from "node:crypto"; |
| 14 | import * as Provider from "../../Provider.ts"; |
| 15 | import { Resource } from "../../Resource.ts"; |
| 16 | import { initialCwd } from "../../Util/Node.ts"; |
| 17 | import { cacheControlOf, contentTypeOf } from "../../Website/assets.ts"; |
| 18 | import type { Bucket } from "../Bucket.ts"; |
| 19 | import { TigrisCredentialsMissing } from "../Errors.ts"; |
| 20 | import type { Providers } from "../Providers.ts"; |
| 21 | |
| 22 | const 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 | */ |
| 30 | const 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 | |
| 39 | export 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 | |
| 59 | export 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 | */ |
| 79 | export const AssetDeployment = Resource<AssetDeployment>( |
| 80 | "Fly.Website.AssetDeployment", |
| 81 | ); |
| 82 | |
| 83 | const normalizePrefix = (prefix: string | undefined) => |
| 84 | prefix ? prefix.replace(/^\/+|\/+$/g, "") : ""; |
| 85 | |
| 86 | const 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 | |
| 106 | const 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 | |
| 131 | const 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 | |
| 151 | const 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 | |
| 182 | const 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 | |
| 200 | export 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 | ); |