Radondocs

Core concepts

The mental model behind Radon Storage — providers, the storage contract, keys, capabilities, signed URLs, test vs live, and the native() escape hatch.

Radon Storage has a small number of ideas. Once they click, every method and every provider behaves predictably.

Providers

A provider is a place objects live — Amazon S3, Cloudflare R2, Supabase Storage, the local filesystem. You configure the ones you use; Radon lazy-loads each adapter only when it's first touched, so an S3-plus-R2 app never bundles Azure's code.

const storage = new RadonStorage({
  providers: {
    s3: { bucket: "uploads", region: "us-east-1" }, // options + creds from env
    r2: { bucket: "backup", accountId: process.env.CF_ACCOUNT },
  },
  defaultProvider: "s3",
});

Every call uses defaultProvider unless you override it per call:

await storage.upload({ key: "a.png", body: buf }, { provider: "r2" });

Each provider reads its credentials from the providers config block first, then falls back to RADON_<SLUG>_* environment variables. Config always wins; whatever you omit is pulled from the environment.

Why swapping is a config change

Every provider implements the same interfaceupload, download, getUrl, list, and so on. The shapes you pass in and get back are identical across providers, so changing your storage backend is a config edit, not a rewrite.

The storage contract

Under the hood, every adapter implements one TypeScript interface, StorageProvider. These are the operations it guarantees:

upload(input) => Promise<UploadResult>

Store an object from bytes, a stream, text, or a local file path.

download(key) => Promise<Buffer>

Read an object's bytes into memory.

delete(key) => Promise<void>

Remove an object. A no-op (not an error) if the key doesn't exist.

exists(key) => Promise<boolean>

Whether an object exists at key.

getMetadata(key) => Promise<ObjectMetadata>

Size, content type, timestamps, and user metadata for one object.

getUrl(key, options) => string | Promise<string>

A public or signed URL for an object.

list(prefix, options) => Promise<ListResult>

A paginated page of objects (and "folder" prefixes) under a prefix.

copy(sourceKey, destKey) => Promise<UploadResult>

Server-side copy, where the provider supports it.

Large-file multipart methods are optional — an adapter that can't do them omits them, and Radon raises a clear UnsupportedOperationError instead of failing silently.

Keys

A key is an object's path within the bucket — avatars/ada.png, reports/2026/q1.pdf. Keys are always bucket-relative: a leading slash is trimmed for you, so /a/b.png and a/b.png address the same object.

await storage.upload({ key: "/avatars/ada.png", body: buf }); // stored as "avatars/ada.png"

Slashes inside a key are preserved and used as "folder" separators by list() when you pass a delimiter.

Capabilities

Providers differ in what they can do — the local filesystem can't sign URLs; Vercel Blob has no server-side copy. Rather than fail unpredictably, every adapter declares a static capabilities descriptor you can read up front:

const s3 = await storage.provider("s3");
s3.capabilities;
// {
//   upload: true, delete: true, publicUrls: true, signedUrls: true,
//   presignedUpload: true, list: true, metadata: true, multipart: true, copy: true,
// }

Call an operation a provider can't do and you get a typed UnsupportedOperationError naming the provider and the operation — never a silent failure. The Providers page lists every provider's capabilities.

Normalized results

Every upload() returns the same UploadResult shape, whatever the provider:

keystring

The stored object key.

providerstring

Which provider stored it ("s3", "r2", …).

sizenumber | undefined

Bytes stored, when the provider reports it.

etagstring | undefined

The entity tag (usually an MD5 or opaque hash) the provider assigned.

urlstring | undefined

A public URL, when one is determinable without signing (public bucket / CDN). Use getUrl(key, { signed: true }) for private objects.

metadataRecord<string, string>

Your user metadata, echoed back.

rawunknown

The untouched provider response — reach here for fields Radon doesn't normalize.

getMetadata(), list(), and the rest are normalized the same way. See the API reference for every return shape.

Public vs. signed URLs

There are two ways to hand out an object:

  • A public URL works only for objects in a public bucket or on a CDN. It's a plain, permanent link. getUrl(key) returns it.
  • A signed URL is time-limited and cryptographically signed, granting access to a private object for a window you choose. getUrl(key, { signed: true }) returns it.
storage.getUrl("report.pdf");                                // public URL (bucket must be public)
await storage.getUrl("report.pdf", { signed: true });        // signed GET, default 1 hour
await storage.getUrl("up.bin", { signed: true, method: "PUT" }); // presigned upload URL

Signed URLs are covered in full on the Signed URLs page.

getUrl and list use the primary provider only

Even with a failover chain configured, getUrl() and list() always target the primary (default) provider — they never fail over. Both are deterministic, per-provider operations: a signed URL is only valid for the store that minted it.

Test vs. live

mode hints sandbox vs production to the providers that distinguish them. Most object stores ignore it — a bucket is a bucket — so it's a no-op for the S3 family, but it flips your whole integration at once for providers that care.

new RadonStorage({ mode: "test", providers: { /* … */ } }); // default
new RadonStorage({ mode: "live", providers: { /* … */ } });

Free, Pro, and init()

Three providers are free (s3, r2, local). Everything else — and the resumable-upload and failover features — is Pro and needs a verified license.

const storage = new RadonStorage({
  providers: { supabase: { bucket: "media" } }, // a Pro provider
  licenseKey: process.env.RADON_LICENSE_KEY,    // or RADON_LICENSE_KEY in the env
});

await storage.init();  // verifies the license once, then unlocks Pro; throws if invalid
await storage.upload({ key: "a.png", body: buf });

init() pings the license service at most once per process and caches the result. It fails closed: a missing key throws LicenseRequiredError, and an invalid or unreachable key throws LicenseInvalidError.

When you must call init()

init() is required whenever you use any Pro provider, a failover chain, or a resumable upload — even resumable upload to a free provider like S3, because chunked upload is itself a Pro feature. Free-only setups that only use upload/download/getUrl/… can skip it entirely.

Escape hatch: native()

The unified API covers the common cases. For anything provider-specific, drop to native() — it returns the adapter's own low-level machinery (its request signer, HTTP client, and resolved endpoint config), so you can call the raw API while staying zero-dependency:

const s3 = await storage.native("s3");
// s3.signer   → SigV4Signer
// s3.http     → HttpClient
// s3.settings → { bucket, region, baseUrl, publicUrl, ... }
const url = s3.signer.presign({ method: "GET", url: s3.objectUrl("k"), expiresIn: 60 });

You can also register your own provider by implementing StorageProvider (or extending BaseProvider, or S3CompatibleProvider for an S3-compatible store) and calling registerProvider(slug, loader). Custom providers are Pro-gated but built exactly like the built-ins.

On this page