cloud_queue
Better MediaDeveloper docs

S3 Compatible

S3-compatible storage adapters such as AWS S3, MinIO, and Cloudflare R2.

This guide covers the S3 storage adapter, which provides support for AWS S3 and other S3-compatible object storage providers like MinIO, DigitalOcean Spaces, and Cloudflare R2.

Installation

pnpm add @better-media/adapter-storage-s3

Configuration

To use the S3StorageAdapter, provide the necessary configuration upon instantiation. The adapter natively supports dynamic bucket resolution and custom endpoints for full compatibility with alternative S3 providers.

import { S3StorageAdapter } from "@better-media/adapter-storage-s3";

const storage = new S3StorageAdapter({
  // Required: AWS region or custom provider region
  region: "us-east-1",

  // Required: Credentials
  accessKeyId: process.env.S3_ACCESS_KEY_ID,
  secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,

  // Required: Bucket name, or a function resolving the bucket dynamically per key
  bucket: process.env.S3_BUCKET_NAME,

  // Optional: Custom endpoint (required for MinIO, R2, etc.)
  endpoint: process.env.S3_ENDPOINT, // e.g., "http://localhost:9000" for MinIO

  // Optional: Force path-style URLs (useful for MinIO and local testing)
  forcePathStyle: true,
});

Dynamic Buckets

If you need to store files in different buckets based on dynamic properties (e.g., separating images and videos, or multi-tenant buckets), you can pass a resolver function to the bucket option:

const storage = new S3StorageAdapter({
  // ...other config
  bucket: (key: string) => {
    if (key.startsWith("videos/")) return "my-videos-bucket";
    return "my-default-bucket";
  },
});

API Reference

The adapter implements the StorageAdapter interface from @better-media/core. Some methods are required on every adapter; others are optional and guarded at the call site.

Core operations

These four methods are required on every storage adapter.

put

put(key: string, value: Buffer, options?: { contentType?: string }): Promise<void>

Writes a buffer to storage. Pass contentType to set the Content-Type header on the stored object — if omitted, S3 defaults to application/octet-stream.

StoragePutOptions only exposes contentType. S3 headers like ContentDisposition, CacheControl, and ContentEncoding are not passed through — if your previous code set these on PutObjectCommand, they will be silently dropped.

get

get(key: string): Promise<Buffer | null>

Returns the full file buffer, or null if the key does not exist. For large files consider getStream to avoid loading the entire object into memory.

delete

delete(key: string): Promise<void>

Deletes a single object. Resolves without error if the key does not exist.

exists

exists(key: string): Promise<boolean>

Issues a HeadObject request and returns true if the object exists, false on a 404. Does not load the file body. Use this to verify a temp upload is present before copying it to its final location.


Presigned uploads

createPresignedUpload

createPresignedUpload(
  key: string,
  options: PresignedUploadOptions
): Promise<PresignedUploadResult>

Generates a time-limited URL the client uses to upload directly to S3, bypassing your server entirely.

PresignedUploadOptions

FieldTypeDefaultDescription
method"POST" | "PUT""PUT"Upload method. See below.
contentTypestringRequired. Enforced by S3 — mismatches are rejected with 403.
expiresInnumber3600URL lifetime in seconds.
maxSizeBytesnumberUpper bound on file size. Enforced server-side by S3.
minSizeBytesnumber1Lower bound on file size (POST only).
metadataRecord<string, string>Stored as x-amz-meta-*. Signed into the policy — mismatches are rejected.

PresignedUploadResult

type PresignedUploadResult =
  | { method: "POST"; url: string; fields: Record<string, string> }
  | { method: "PUT";  url: string; headers: Record<string, string> }

POST — for browser form uploads. Returns a url and a fields map. All fields entries must be appended to the FormData body before the file field:

const { url, fields } = await storage.createPresignedUpload(key, {
  method: "POST",
  contentType: "image/jpeg",
  maxSizeBytes: 5 * 1024 * 1024,
});

const form = new FormData();
for (const [k, v] of Object.entries(fields)) form.append(k, v);
form.append("file", fileBlob); // must be last
await fetch(url, { method: "POST", body: form });

S3 enforces the Content-Type equality condition and content-length-range server-side. Uploads that violate either are rejected at the edge — your server is never involved.

PUT — for programmatic or mobile uploads. Returns a single signed url and required headers:

const { url, headers } = await storage.createPresignedUpload(key, {
  method: "PUT",
  contentType: "video/mp4",
  maxSizeBytes: 500 * 1024 * 1024,
});

await fetch(url, { method: "PUT", headers, body: fileBlob });

Presigned GET

getUrl

getUrl(key: string, options?: { expiresIn?: number }): Promise<string>

Returns a signed URL for reading the object. Always signed — there is no code path that returns a plain public URL. When expiresIn is omitted the adapter uses a default of 3600 seconds.

If you need a permanent public URL (e.g. for logos or avatars on a public bucket), construct it directly from the bucket and key rather than calling getUrl:

const publicUrl = `https://${bucket}.s3.${region}.amazonaws.com/${key}`;

Or store the public URL at upload time and read it from your database instead of regenerating it on every request.


Object operations

These methods are optional on the StorageAdapter interface. The S3 adapter implements all of them; check typeof storage.method === "function" before calling them against an unknown adapter.

copy

copy(source: string, destination: string): Promise<void>

Issues a server-side CopyObject — no data travels through your application. Both source and destination are raw storage keys; the bucket is resolved internally. Object metadata is preserved by default.

Returns void. Unlike CopyObjectCommand, the adapter does not surface the ETag or LastModified of the new object. If you need the ETag after copying, call exists or fetch it separately.

move

move(source: string, destination: string): Promise<void>

copy followed by delete on the source. Atomic at the object level but not transactional — if delete fails after a successful copy, the source remains.

deleteMany

deleteMany(keys: string[]): Promise<void>

Batches deletions using S3's native DeleteObjects API (up to 1,000 keys per request). Prefer this over looping delete for bulk removals.


Confirm-upload pattern

A common pattern for client-side uploads is staging the file under a temp key, verifying it arrived, then moving it to its permanent location:

// 1. Generate presigned POST for a temp key
const upload = await storage.createPresignedUpload(`temp/${id}`, {
  method: "POST",
  contentType,
  maxSizeBytes,
});

// 2. Client uploads directly to S3 using upload.url + upload.fields

// 3. Server confirms the file is present
const arrived = await storage.exists(`temp/${id}`);
if (!arrived) throw new Error("Upload not found");

// 4. Move to permanent location
await storage.copy(`temp/${id}`, `media/${id}`);
await storage.delete(`temp/${id}`);

Multipart uploads

For files too large to upload in a single request, the adapter exposes a lower-level multipart API: createMultiPart, putItemMultiPart, completeMultipart, and abortMultipart. These map directly to the S3 multipart upload lifecycle.


Bucket configuration

The adapter can configure S3 bucket settings programmatically — lifecycle rules, CORS, and public access blocks — without needing the AWS CLI or console. See the source for setBucketLifecycle, setBucketCors, and disablePublicAccess.

For the core storage interface contract, see Storage Architecture.


Migrating from the AWS SDK

If you are replacing direct @aws-sdk/client-s3 usage with this adapter, the method mapping is straightforward:

AWS SDKAdapter
PutObjectCommandstorage.put(key, buffer, { contentType })
GetObjectCommand + getSignedUrlstorage.getUrl!(key, { expiresIn })
HeadObjectCommand (throws on 404)storage.exists(key) (returns boolean)
CopyObjectCommandstorage.copy!(source, destination)
DeleteObjectCommandstorage.delete(key)
createPresignedPoststorage.createPresignedUpload!(key, { method: "POST", ... })

There are four behavioral differences to be aware of:

ACL removal. The adapter never sets per-object ACLs. If your previous code used ACL: "public-read" on PutObjectCommand, objects will be private after migration with no error or warning — your app will silently stop serving those files. Replace per-object ACLs with a bucket policy that grants s3:GetObject on the relevant prefix:

{
  "Effect": "Allow",
  "Principal": "*",
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::your-bucket/public/*"
}

getUrl always signs. The adapter always returns a signed URL with a default expiry of 3600 seconds — it never returns a plain public URL. If your old code served permanent public URLs for assets on a public bucket, those URLs will now expire. Store the public URL at upload time or construct it directly from the bucket and key.

put drops object headers. StoragePutOptions only accepts contentType. Headers like ContentDisposition, CacheControl, and ContentEncoding are not forwarded to S3. If your old PutObjectCommand calls set any of these, they are silently dropped after migration.

copy returns void. CopyObjectCommand returns the new object's ETag and LastModified. The adapter's copy returns nothing. If your old code read the ETag from the copy response, that logic will need to change.

404 errors are swallowed. The adapter catches 404s internally: get returns null, exists returns false, delete resolves silently. Any try/catch blocks that previously caught a NoSuchKey error from these operations will no longer fire.

On this page