import { S3Client, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, } from "@aws-sdk/client-s3"; import { Readable } from "node:stream"; import type { StorageProvider, GetObjectResult, HeadObjectResult } from "./types.js"; import { notFound, unprocessable } from "../errors.js"; interface S3ProviderConfig { bucket: string; region: string; endpoint?: string; prefix?: string; forcePathStyle?: boolean; } function normalizePrefix(prefix: string | undefined): string { if (!prefix) return ""; return prefix .trim() .replace(/^\/+/, "") .replace(/\/+$/, ""); } function buildKey(prefix: string, objectKey: string): string { if (!prefix) return objectKey; return `${prefix}/${objectKey}`; } async function toReadableStream(body: unknown): Promise { if (!body) throw notFound("Object not found"); if (body instanceof Readable) return body; const candidate = body as { transformToWebStream?: () => ReadableStream; arrayBuffer?: () => Promise; }; if (typeof candidate.transformToWebStream === "function") { const webStream = candidate.transformToWebStream(); const reader = webStream.getReader(); return Readable.from((async function* () { while (true) { const { done, value } = await reader.read(); if (done) break; if (value) yield value; } })()); } if (typeof candidate.arrayBuffer === "function") { const buffer = Buffer.from(await candidate.arrayBuffer()); return Readable.from(buffer); } throw unprocessable("Unsupported S3 body stream type"); } function toDate(value: Date | undefined): Date | undefined { return value instanceof Date ? value : undefined; } export function createS3StorageProvider(config: S3ProviderConfig): StorageProvider { const bucket = config.bucket.trim(); const region = config.region.trim(); if (!bucket) throw unprocessable("S3 storage bucket is required"); if (!region) throw unprocessable("S3 storage region is required"); const prefix = normalizePrefix(config.prefix); const client = new S3Client({ region, endpoint: config.endpoint, forcePathStyle: Boolean(config.forcePathStyle), }); return { id: "s3", async putObject(input) { const key = buildKey(prefix, input.objectKey); await client.send( new PutObjectCommand({ Bucket: bucket, Key: key, Body: input.body, ContentType: input.contentType, ContentLength: input.contentLength, }), ); }, async getObject(input): Promise { const key = buildKey(prefix, input.objectKey); try { const output = await client.send( new GetObjectCommand({ Bucket: bucket, Key: key, }), ); return { stream: await toReadableStream(output.Body), contentType: output.ContentType, contentLength: output.ContentLength, etag: output.ETag, lastModified: toDate(output.LastModified), }; } catch (err) { const code = (err as { name?: string }).name; if (code === "NoSuchKey" || code === "NotFound") throw notFound("Object not found"); throw err; } }, async headObject(input): Promise { const key = buildKey(prefix, input.objectKey); try { const output = await client.send( new HeadObjectCommand({ Bucket: bucket, Key: key, }), ); return { exists: true, contentType: output.ContentType, contentLength: output.ContentLength, etag: output.ETag, lastModified: toDate(output.LastModified), }; } catch (err) { const code = (err as { name?: string }).name; if (code === "NoSuchKey" || code === "NotFound") return { exists: false }; throw err; } }, async deleteObject(input): Promise { const key = buildKey(prefix, input.objectKey); await client.send( new DeleteObjectCommand({ Bucket: bucket, Key: key, }), ); }, }; }