Skip to main content
CloudflareStorage is the Storage component’s Cloudflare R2 backend. It extends the shared Storage base class, which talks to R2 through Bun’s built-in S3 client — R2 is S3-compatible — so you get production object storage with zero egress fees behind the same IStorage interface as every other backend. Switch to or from R2 without changing a single call site.

Why Cloudflare R2

  • S3-compatible. Backed by Bun’s native S3Client; CloudflareStorage only supplies the connection options.
  • Same IStorage interface. put, getFile, getAsJson, delete, list, and more — identical to every other storage backend.
  • Shared base class. Inherits all transport logic from Storage; the subclass just provides credentials and getOptions().
  • Flexible content. put() accepts strings, ArrayBuffer, Blob, Request/Response, and BunFile/S3File.
  • Container-managed. Registered with @decorator.storage() and resolved from the container.

Installation

CloudflareStorage ships with @ooneex/storage. It uses Bun’s built-in S3 client, so there’s no extra SDK to install.
bun add @ooneex/storage

Environment variables

VariableRequiredPurpose
STORAGE_CLOUDFLARE_ACCESS_KEYYesR2 access key. Missing throws StorageException (CONFIG_REQUIRED).
STORAGE_CLOUDFLARE_SECRET_KEYYesR2 secret key. Missing throws StorageException (CONFIG_REQUIRED).
STORAGE_CLOUDFLARE_ENDPOINTYesR2 S3 endpoint URL. Missing throws StorageException (CONFIG_REQUIRED).
STORAGE_CLOUDFLARE_REGIONNoR2 region. Defaults to EEUR.
STORAGE_CLOUDFLARE_ACCESS_KEY=your_access_key
STORAGE_CLOUDFLARE_SECRET_KEY=your_secret_key
STORAGE_CLOUDFLARE_ENDPOINT=https://your-account.r2.cloudflarestorage.com
STORAGE_CLOUDFLARE_REGION=EEUR
All three required values are validated when CloudflareStorage is constructed, so missing credentials fail fast at startup.

How it works

CloudflareStorage reads its credentials, then getOptions() returns the S3Options the base Storage class uses to build a Bun.S3Client. Calling setBucket() constructs (or rebuilds) the client for that bucket; from then on every read and write goes through the S3 client. The active bucket is the R2 bucket the client targets.
MethodPurpose
getBucket() / setBucket(name)Read or switch the active R2 bucket; setBucket chains.
list()List the keys in the current bucket.
clearBucket()Delete every object in the current bucket.
exists(key)Whether an object exists.
delete(key)Remove one object.
put(key, content)Write content; returns the number of bytes written.
putFile(key, localPath)Upload a local file.
putDir(bucket, options)Upload a local directory recursively with an optional filter regex.
getFile(key, options)Download an object to options.outputDir (optionally renamed).
getAsJson<T>(key)Parse the object as JSON.
getAsArrayBuffer(key)Read the object as an ArrayBuffer.
getAsStream(key)Read the object as a ReadableStream.

Usage

The API is the same as every other storage backend.
import { container } from "@ooneex/container";
import { CloudflareStorage } from "@ooneex/storage";

const storage = container.get(CloudflareStorage).setBucket("uploads");

// Write content — returns the number of bytes written
await storage.put("avatars/123.png", file); // file: Blob | BunFile | ArrayBuffer | ...
await storage.put("config.json", JSON.stringify({ theme: "dark" }));

// Read it back
const config = await storage.getAsJson<{ theme: string }>("config.json");
const buffer = await storage.getAsArrayBuffer("avatars/123.png");

// Existence, listing, and removal
await storage.exists("avatars/123.png"); // true
const keys = await storage.list();
await storage.delete("avatars/123.png");
Upload a local file or a whole directory, and stream large files instead of buffering them:
// Upload one local file, or a directory recursively (images only)
await storage.putFile("avatars/123.png", "/tmp/avatar.png");
await storage.putDir("avatars", {
  path: "/tmp/avatars",
  filter: /\.(png|jpe?g)$/,
});

// Stream a large object straight to the response
const stream = storage.getAsStream("videos/intro.mp4");
return new Response(stream);

Custom buckets

Register a named storage under your own class so each bucket has a dedicated, injectable type:
import { decorator } from "@ooneex/storage";
import { CloudflareStorage } from "@ooneex/storage";

@decorator.storage()
export class UploadStorage extends CloudflareStorage {}
import { inject } from "@ooneex/container";

export class UploadService {
  constructor(@inject(UploadStorage) private readonly storage: UploadStorage) {}
}

Use in the app

In an @ooneex/app application, storage isn’t a dedicated App config slot — CloudflareStorage registers itself with the container through @decorator.storage() as soon as the class is imported, and you inject it wherever you read or write files.
bun add @ooneex/app @ooneex/storage
Inject it into a service or controller, set the bucket, and use the IStorage methods:
import { inject } from "@ooneex/container";
import { CloudflareStorage } from "@ooneex/storage";

export class MediaService {
  constructor(
    @inject(CloudflareStorage) private readonly storage: CloudflareStorage,
  ) {}

  public async uploadAvatar(userId: string, file: Blob): Promise<void> {
    await this.storage.setBucket("uploads").put(`avatars/${userId}.png`, file);
  }
}
To bind a dedicated R2 bucket to its own injectable type, register a subclass with @decorator.storage():
import { decorator } from "@ooneex/storage";
import { CloudflareStorage } from "@ooneex/storage";

@decorator.storage()
export class AvatarStorage extends CloudflareStorage {}
Set STORAGE_CLOUDFLARE_ACCESS_KEY, STORAGE_CLOUDFLARE_SECRET_KEY, STORAGE_CLOUDFLARE_ENDPOINT, and (optionally) STORAGE_CLOUDFLARE_REGION in your .env.yml (or environment) so the backend can connect.

Exceptions

CloudflareStorage throws StorageException on misconfiguration or a failed operation, carrying a machine-readable key.
KeyWhen
CONFIG_REQUIREDMissing the access key, secret key, or endpoint.
STORAGE_UPLOAD_FAILEDA write failed.
STORAGE_DOWNLOAD_FAILEDA read or download failed.
STORAGE_LIST_FAILEDListing a bucket failed.
FILE_NOT_FOUNDA read targeted an object that does not exist.
import { StorageException } from "@ooneex/storage";

try {
  await storage.put("avatars/123.png", file);
} catch (error) {
  if (error instanceof StorageException) {
    logger.error(`Storage error [${error.key}]: ${error.message}`, error.data);
  }
  throw error;
}

Best practices

  • Set the bucket first. Call setBucket() before reads or writes — it also builds the S3 client for that bucket.
  • Use the right endpoint. Point STORAGE_CLOUDFLARE_ENDPOINT at your account’s R2 S3 endpoint, not the public bucket URL.
  • Use stable, namespaced keys. Group objects under prefixes like avatars/123.png so list() and clearBucket() stay predictable.
  • Stream large files. Prefer getAsStream() and putFile/putDir over loading whole files into memory.
  • Keep credentials in the environment. Load the access key, secret key, and endpoint from .env; never hard-code them.
  • Subclass per bucket. Register a CloudflareStorage subclass for each bucket so it can be injected with its own type.
See the Storage component for the full interface and the other backends, including Bunny Storage.