Skip to main content
The @ooneex/storage component is a multi-backend file storage layer. Every backend implements the same IStorage interface — put, getFile, getAsJson, delete, list, and more — so you can start on the local filesystem and move to Cloudflare R2 or Bunny without changing a single call site. Files are organized into buckets, and the S3-compatible backends share one abstract Storage base class.

Why this component

  • One interface, many backends. Filesystem, Cloudflare R2, and Bunny all implement IStorage — swap backends without touching callers.
  • Bucket-based. Group files under a bucket with setBucket() / getBucket(); list or clear a whole bucket in one call.
  • Flexible content. put() accepts strings, ArrayBuffer, Blob, Request/Response, and BunFile/S3File.
  • Read it your way. Pull a file back as JSON, an ArrayBuffer, a stream, or write it straight to disk with getFile().
  • Directory uploads. putDir() walks a local directory recursively with an optional regex filter.
  • Container-managed. Register a storage class with a decorator and resolve it from the container.

How it works

You pick (or implement) a backend and register it. Reads and writes go through the IStorage methods; the backend handles the transport, bucketing, and content conversion.
MethodPurpose
getBucket()Return the active bucket name.
setBucket(name)Switch the active bucket; returns the storage for chaining.
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.
getFile(key, options)Download an object to options.outputDir (optionally renamed via options.filename).
getAsJson<T>(key)Parse the object as JSON.
getAsArrayBuffer(key)Read the object as an ArrayBuffer.
getAsStream(key)Read the object as a ReadableStream.
The built-in backends differ in where files live, not in how you call them:
BackendBest forTransport
FilesystemStorageLocal development, single-instance storageLocal disk
CloudflareStorageProduction object storage on Cloudflare R2S3-compatible (HTTPS)
BunnyStorageEdge file delivery via Bunny Storage zonesBunny Storage SDK (HTTPS)
FilesystemStorage and CloudflareStorage extend the shared Storage base class and talk to an S3 client under the hood. BunnyStorage implements IStorage directly against the Bunny Storage SDK.

Environment variables

VariableBackendRequiredPurpose
FILESYSTEM_STORAGE_PATHFilesystemStorageYesBase directory for stored files. Missing throws StorageException (STORAGE_ROOT_DIR_REQUIRED).
STORAGE_CLOUDFLARE_ACCESS_KEYCloudflareStorageYesR2 access key. Missing throws StorageException (CONFIG_REQUIRED).
STORAGE_CLOUDFLARE_SECRET_KEYCloudflareStorageYesR2 secret key. Missing throws StorageException (CONFIG_REQUIRED).
STORAGE_CLOUDFLARE_ENDPOINTCloudflareStorageYesR2 S3 endpoint URL. Missing throws StorageException (CONFIG_REQUIRED).
STORAGE_CLOUDFLARE_REGIONCloudflareStorageNoR2 region. Defaults to EEUR.
STORAGE_BUNNY_ACCESS_KEYBunnyStorageYesBunny storage access key. Missing throws StorageException (API_KEY_REQUIRED).
STORAGE_BUNNY_STORAGE_ZONEBunnyStorageYesBunny storage zone name. Missing throws StorageException (STORAGE_ZONE_REQUIRED).
STORAGE_BUNNY_REGIONBunnyStorageNoBunny region code (de, uk, ny, la, sg, se, br, jh, syd). Defaults to de.
FILESYSTEM_STORAGE_PATH=./storage
STORAGE_CLOUDFLARE_ACCESS_KEY=your_access_key
STORAGE_CLOUDFLARE_SECRET_KEY=your_secret_key
STORAGE_CLOUDFLARE_ENDPOINT=https://your-account.r2.cloudflarestorage.com
STORAGE_BUNNY_ACCESS_KEY=your_access_key
STORAGE_BUNNY_STORAGE_ZONE=your-zone

Usage

The API is the same regardless of backend.
import { CloudflareStorage } from "@ooneex/storage";
import { container } from "@ooneex/container";

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 an entire directory, and download objects to disk:
// Upload one local file
await storage.putFile("avatars/123.png", "/tmp/avatar.png");

// Upload a directory recursively, keeping only images
await storage.putDir("avatars", {
  path: "/tmp/avatars",
  filter: /\.(png|jpe?g)$/,
});

// Download an object to a local directory
await storage.getFile("avatars/123.png", {
  outputDir: "/tmp/downloads",
  filename: "user-avatar.png",
});
For large files, stream instead of buffering the whole object in memory:
const stream = storage.getAsStream("videos/intro.mp4");
return new Response(stream);

Decorator and usage

@decorator.storage()

Registers a storage class with the container. It accepts an optional scope (defaults to singleton). Use it to register a built-in backend under your own name, or a custom backend that extends Storage or implements IStorage.
import { decorator } from "@ooneex/storage";
import { CloudflareStorage } from "@ooneex/storage";

@decorator.storage()
export class UploadStorage extends CloudflareStorage {}
Resolve it from the container and inject it where needed:
import { inject } from "@ooneex/container";

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

Exceptions

The component throws StorageException when a backend is misconfigured or an operation fails. It carries a machine-readable key, a human-readable message, and a data object.
KeyWhen
STORAGE_ROOT_DIR_REQUIREDFilesystemStorage is created without FILESYSTEM_STORAGE_PATH.
CONFIG_REQUIREDCloudflareStorage is missing its access key, secret key, or endpoint.
API_KEY_REQUIREDBunnyStorage is missing its access key.
STORAGE_ZONE_REQUIREDBunnyStorage is missing its storage zone.
STORAGE_BUCKET_REQUIREDA FilesystemStorage operation runs before setBucket().
STORAGE_UPLOAD_FAILEDA write or directory creation failed.
STORAGE_DOWNLOAD_FAILEDA read or download failed.
STORAGE_LIST_FAILEDListing a bucket failed.
STORAGE_DELETE_FAILEDDeleting an object failed.
STORAGE_CLEAR_FAILEDClearing a bucket failed.
FILE_NOT_FOUNDA read targeted an object that does not exist.
UNSUPPORTED_CONTENT_TYPEBunnyStorage.put() received content it cannot convert.
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);
  } else {
    throw error;
  }
}

Best practices

  • Set the bucket first. Call setBucket() before reads or writes — FilesystemStorage throws STORAGE_BUCKET_REQUIRED otherwise.
  • Use stable, namespaced keys. Group related objects under prefixes like avatars/123.png so list() and clearBucket() stay predictable.
  • Stream large files. Prefer getAsStream() (and putDir/putFile for local sources) over loading whole files into memory.
  • Filter directory uploads. Pass a filter regex to putDir() so you only ship the files you intend to.
  • Pick the backend per environment. Filesystem for local/dev, Cloudflare R2 or Bunny for production — the code stays identical.
  • Handle the missing case. Reads throw FILE_NOT_FOUND (or STORAGE_DOWNLOAD_FAILED) when an object is absent; catch StorageException and branch on error.key.
  • Throw StorageException with a stable key. In custom backends, keep keys constant and put variable detail in data.

CLI command

Scaffold a storage adapter and its test file with the generator. It writes the class under modules/<module>/src/storage/<Name>Storage.ts and installs @ooneex/storage if it is missing.
# Interactive: prompts for the name
ooneex storage:create

# Provide the name
ooneex storage:create --name=Upload

# Target a module and overwrite
ooneex storage:create --name=Upload --module=media --override
OptionDescriptionDefault
--nameStorage class name. The Storage suffix is appended automatically.Prompted if omitted
--moduleTarget module the class is generated into.shared
--overrideOverwrite an existing class without prompting.false
The generated class extends Storage as an S3-compatible adapter, ready for you to set the bucket and confirm its credentials:
import { Storage, decorator, StorageException } from "@ooneex/storage";
import { inject } from "@ooneex/container";
import { AppEnv } from "@ooneex/app-env";
import type { S3Options } from "bun";

@decorator.storage()
export class UploadStorage extends Storage {
  protected bucket: string;
  private readonly accessKey: string;
  private readonly secretKey: string;
  private readonly endpoint: string;
  private readonly region: string;

  constructor(
    @inject(AppEnv) private readonly env: AppEnv,
    options?: {
      accessKey?: string;
      secretKey?: string;
      endpoint?: string;
      region?: string;
    },
  ) {
    super();

    const accessKey = options?.accessKey || this.env.STORAGE_UPLOAD_ACCESS_KEY;
    const secretKey = options?.secretKey || this.env.STORAGE_UPLOAD_SECRET_KEY;
    const endpoint = options?.endpoint || this.env.STORAGE_UPLOAD_ENDPOINT;

    if (!accessKey) {
      throw new StorageException(
        "Upload access key is required. Please provide an access key either through the constructor options or set the STORAGE_UPLOAD_ACCESS_KEY environment variable.",
      );
    }
    if (!secretKey) {
      throw new StorageException(
        "Upload secret key is required. Please provide a secret key either through the constructor options or set the STORAGE_UPLOAD_SECRET_KEY environment variable.",
      );
    }
    if (!endpoint) {
      throw new StorageException(
        "Upload endpoint is required. Please provide an endpoint either through the constructor options or set the STORAGE_UPLOAD_ENDPOINT environment variable.",
      );
    }

    this.accessKey = accessKey;
    this.secretKey = secretKey;
    this.endpoint = endpoint;
    this.region = options?.region || this.env.STORAGE_UPLOAD_REGION || "auto";
  }

  public getOptions(): S3Options {
    return {
      accessKeyId: this.accessKey,
      secretAccessKey: this.secretKey,
      endpoint: this.endpoint,
      bucket: this.bucket,
      region: this.region,
    };
  }
}
See storage:create for the full command reference.

Use with Claude and Codex

The generator ships a matching storage:create skill. It runs the scaffold and then guides your AI agent through completing the adapter — setting the bucket, wiring the credential env vars, and finishing the test file. Initialize the skills once for your agent:
ooneex claude:init
Then ask Claude in natural language — it maps the request to the generator, runs it, and fills in the implementation:
Prompt
Create a storage adapter for user uploads backed by S3.
For example, the prompt above maps to storage:create --name=Upload, then sets the bucket and credential env vars on the generated Storage subclass.