@radonsdk/storage
v0.1.1
Published
One unified, provider-agnostic API for 25+ file-storage providers — object storage, CDN/media, self-hosted, and dev/local. Write storage.upload() once, swap providers via config. Signed URLs, resumable/chunked uploads, multi-provider failover, and bring-y
Readme
@radonsdk/storage
One unified, provider-agnostic API for 25+ file-storage providers — object storage, CDN/media, self-hosted, and dev/local. Write
storage.upload()once and swap providers with a config change, never a code change.
Radon Storage gives you a single, typed interface (upload, download, delete, getUrl, list, exists, getMetadata, …) that every provider implements. Provider-specific quirks — auth schemes (AWS SigV4, Azure Shared Key, OSS V1, token headers), virtual-hosted vs path-style URLs, XML vs JSON responses, multipart protocols — are absorbed inside each adapter and never leak to your code. Own your data, no vendor lock-in.
- 25+ providers, one API — S3, Cloudflare R2, Supabase, GCS, Azure Blob, Backblaze, Spaces, MinIO, Wasabi, Bunny, ImageKit, Cloudinary, and more.
- Signed & public URLs — presigned GET/PUT for private objects, plain URLs for public ones, one
getUrl(). - Resumable / chunked uploads for large files, and multi-provider failover — both built in (Pro).
- Bring-your-own-provider — implement one interface, register a slug.
- Lazy-loaded adapters — an S3 + R2 app never bundles Azure's code.
- Strict TypeScript, ESM + CJS, Node ≥ 18, zero required dependencies (every provider's wire protocol is hand-rolled — no
@aws-sdk/*, no@azure/*, no@supabase/*).
Install
npm install @radonsdk/storage
# or: pnpm add @radonsdk/storageRadon never stores your secrets. Each adapter reads credentials from RADON_<PROVIDER>_* environment variables (or from the providers config block, which takes precedence). See .env.example for every provider's variable names.
Quickstart (< 5 minutes)
import { RadonStorage } from "@radonsdk/storage";
const storage = new RadonStorage({
providers: { s3: { bucket: "uploads", region: "us-east-1" } }, // creds from RADON_S3_*
defaultProvider: "s3",
});
// Upload bytes, a stream, text, or straight from a file path:
const { key, url } = await storage.upload({ key: "avatars/ada.png", path: "./ada.png" });
// A time-limited signed URL for a private object:
const link = await storage.getUrl("avatars/ada.png", { signed: true, expiresIn: 3600 });
// Read it back, check it exists, get metadata, list, delete:
const bytes = await storage.download("avatars/ada.png");
await storage.exists("avatars/ada.png"); // true
await storage.getMetadata("avatars/ada.png"); // { size, contentType, lastModified, ... }
await storage.list("avatars/"); // { objects, prefixes, cursor, hasMore }
await storage.delete("avatars/ada.png");Switch providers without touching your upload code — it's a config change:
const storage = new RadonStorage({
providers: {
s3: { bucket: "uploads", region: "us-east-1" },
r2: { bucket: "uploads", accountId: process.env.CF_ACCOUNT! },
},
});
await storage.upload({ key: "a.png", body: buf }, { provider: "s3" });
await storage.upload({ key: "a.png", body: buf }, { provider: "r2" }); // same code, different storeFree vs. Pro
Three providers are free. The rest — and the resumable-upload and failover features — require a Radon Pro license key, verified once in await storage.init() and cached for the process lifetime.
| Tier | Capability |
| --- | --- |
| Free | s3, r2, local |
| Pro (license) | every other provider (see catalog) + resumable/chunked upload + multi-provider failover + any bring-your-own provider |
const storage = new RadonStorage({
providers: { supabase: { bucket: "media" } }, // a Pro provider
licenseKey: process.env.RADON_LICENSE_KEY, // or config.license: { key, verifyUrl, ... }
});
await storage.init(); // verifies the license (throws if invalid); unlocks Pro
await storage.upload({ key: "a.png", body: buf });- Free providers work with no license and no
init(). - Using a Pro provider/feature without a valid license throws
LicenseRequiredError; a bad/unreachable key throwsLicenseInvalidError(fail-closed). - Get a license at https://radonsdk.xyz/pricing. Introspect tiers with the exported
FREE_PROVIDERSset andisProProvider(slug).
Free/Pro is a licensing concern at the API layer — every adapter, free or Pro, is built to the same completeness bar (real API calls, signed URLs, multipart where the provider supports it).
The unified API
// Upload: bytes / Uint8Array / stream / UTF-8 string via `body`, or a local file via `path`.
await storage.upload({ key: "docs/report.pdf", path: "./report.pdf", cacheControl: "public, max-age=31536000" });
await storage.upload({ key: "note.txt", body: "hello", metadata: { author: "ada" } });
// URLs — public or signed (GET download, or PUT for direct browser upload).
storage.getUrl("docs/report.pdf"); // public URL
await storage.getUrl("docs/report.pdf", { signed: true }); // signed GET (default 1h)
await storage.getUrl("up.bin", { signed: true, method: "PUT" }); // presigned upload URL
await storage.getUrl("r.pdf", { signed: true, download: "Report.pdf" }); // force download
// Read / inspect / enumerate.
await storage.download("note.txt"); // Buffer
await storage.exists("note.txt"); // boolean
await storage.getMetadata("note.txt"); // ObjectMetadata
await storage.list("docs/", { limit: 100, delimiter: "/" }); // page of objects + "folder" prefixes
await storage.copy("a.txt", "b.txt"); // server-side copy (where supported)Operations a provider can't do throw a typed UnsupportedOperationError — never a silent failure — and provider.capabilities tells you up front what's available (signedUrls, multipart, publicUrls, list, …).
Resumable / chunked uploads (Pro)
Large files upload in parts, with automatic abort-on-failure so no half-finished object is left behind. Objects smaller than one part fall back to a single upload:
await storage.init(); // Pro feature — needs a license
await storage.uploadResumable(
{ key: "video/large.mp4", path: "./large.mp4" },
{ partSize: 8 * 1024 * 1024 }, // 8 MiB parts (the default)
);Or drive the multipart primitives yourself (e.g. browser-chunked uploads):
const mp = await storage.createMultipartUpload("big.bin");
const p1 = await storage.uploadPart(mp, 1, chunk1);
const p2 = await storage.uploadPart(mp, 2, chunk2);
await storage.completeMultipartUpload(mp, [p1, p2]);Available wherever capabilities.multipart is true (the S3 family, Azure).
Multi-provider failover (Pro)
Give an ordered chain of providers; each operation tries them in turn until one succeeds:
const storage = new RadonStorage({
providers: {
s3: { bucket: "primary", region: "us-east-1" },
r2: { bucket: "backup", accountId: process.env.CF_ACCOUNT! },
},
failover: ["s3", "r2"], // try S3, fall back to R2
licenseKey: process.env.RADON_LICENSE_KEY,
});
await storage.init();
await storage.upload({ key: "a.png", body: buf }); // lands on the first provider that accepts itIf every provider in the chain fails, an AllProvidersFailedError carries each underlying error in order. Naming a provider explicitly ({ provider: "s3" }) bypasses failover for that call.
Bring your own provider
Implement the StorageProvider interface (or extend BaseProvider, or S3CompatibleProvider for an S3-compatible store) and register it. Custom providers are Pro-gated.
import { BaseProvider, registerProvider } from "@radonsdk/storage";
class MyStoreProvider extends BaseProvider {
readonly name = "my-store";
readonly capabilities = {
upload: true, delete: true, publicUrls: true, signedUrls: false,
presignedUpload: false, list: true, metadata: true, multipart: false, copy: false,
};
async upload(input) { /* call your API via this.http(...) */ }
async delete(key) { /* … */ }
async exists(key) { /* … */ }
async getMetadata(key) { /* … */ }
async download(key) { /* … */ }
getUrl(key, options) { /* … */ }
async list(prefix, options) { /* … */ }
native() { return this.http(""); }
}
registerProvider("my-store", async () => MyStoreProvider);An S3-compatible store is even shorter — just resolve its endpoint:
import { S3CompatibleProvider, registerProvider } from "@radonsdk/storage";
class MyS3Provider extends S3CompatibleProvider {
readonly name = "my-s3";
protected readonly defaultRegion = "us-east-1";
protected endpointFor({ region, bucket, customEndpoint }) {
return { baseUrl: `${customEndpoint}/${bucket}`, region };
}
}
registerProvider("my-s3", async () => MyS3Provider);The native-client escape hatch
Radon is zero-dependency and hand-rolls each provider's wire protocol, so native() returns the adapter's own low-level machinery — its SigV4 signer, HTTP client, and resolved endpoint/bucket config — for anything the unified API doesn't cover:
const s3 = await storage.native("s3");
// s3.signer → SigV4Signer s3.http → HttpClient s3.settings → { bucket, region, baseUrl, ... }
const url = s3.signer.presign({ method: "GET", url: s3.objectUrl("k"), expiresIn: 60 });The signer (SigV4Signer, uriEncode), HTTP client (HttpClient), signature helpers (hmac, sha256Hex, safeEqual), and XML readers are all exported for building against a provider's raw API directly.
Provider catalog
25+ providers, fully implemented — real API calls, signed URLs, and multipart where the provider supports it. Import any adapter directly for an explicit dependency: import { S3Provider } from "@radonsdk/storage/providers/s3".
Object storage (S3-compatible)
| Slug | Provider | Tier | Notes |
| --- | --- | --- | --- |
| s3 | Amazon S3 | Free | virtual-hosted, SigV4, multipart, presigned URLs |
| r2 | Cloudflare R2 | Free | path-style, region auto, multipart |
| backblaze | Backblaze B2 | Pro | S3 API, regional cluster host |
| spaces | DigitalOcean Spaces | Pro | regional host + CDN public URLs |
| minio | MinIO (self-hosted) | Pro | path-style against your endpoint |
| wasabi | Wasabi | Pro | regional S3 host |
| linode | Linode / Akamai | Pro | regional cluster host |
| vultr | Vultr Object Storage | Pro | regional host, signs us-east-1 |
| ibm | IBM Cloud Object Storage | Pro | path-style, HMAC creds |
| oracle | Oracle Cloud Storage | Pro | namespaced regional host |
| scaleway | Scaleway | Pro | regional S3 host |
| alibaba | Alibaba Cloud OSS | Pro | native OSS V1 signature |
| gcs | Google Cloud Storage | Pro | S3-interop XML API + HMAC keys |
Self-hosted / niche
| Slug | Provider | Tier | Notes |
| --- | --- | --- | --- |
| ceph | Ceph RADOS Gateway | Pro | S3-compatible, self-hosted |
| storj | Storj | Pro | decentralized, S3 gateway |
| filebase | Filebase (IPFS) | Pro | IPFS-backed, S3 API |
| tigris | Tigris | Pro | global S3, region auto |
| seaweedfs | SeaweedFS | Pro | self-hosted S3 gateway |
Native-API object storage
| Slug | Provider | Tier | Notes |
| --- | --- | --- | --- |
| supabase | Supabase Storage | Pro | REST API, signed + public URLs |
| azure | Azure Blob Storage | Pro | Shared Key auth, block multipart, SAS URLs |
Dev / simple
| Slug | Provider | Tier | Notes |
| --- | --- | --- | --- |
| local | Local filesystem | Free | files under a root dir, file:// or static URLs |
| vercel-blob | Vercel Blob | Pro | token API, public URLs |
| uploadthing | UploadThing-style | Pro | register→PUT handshake, CDN URLs |
CDN / media-focused
| Slug | Provider | Tier | Notes |
| --- | --- | --- | --- |
| bunny | Bunny.net Storage | Pro | AccessKey API, pull-zone + token URLs |
| imagekit | ImageKit | Pro | multipart upload, ik-s signed URLs |
| cloudinary | Cloudinary | Pro | signed upload, s--sig-- delivery URLs |
A few adapters have wire details flagged VERIFY vs live (bespoke signers / evolving APIs) — see DEFERRED.md.
Uploads, keys & metadata
- Body:
upload({ key, body })accepts aBuffer,Uint8Array, a NodeReadablestream, or a UTF-8string. To upload from disk, passpathinstead ofbody. - Keys are always bucket-relative; leading slashes are trimmed.
list()supports adelimiterto get "folders" (common prefixes) instead of every key. - Content type is inferred from the key/path extension when you don't pass
contentType. - Metadata (
metadata: { … }) is stored as provider user-metadata (x-amz-meta-*,x-ms-meta-*, …) and returned bygetMetadata(). - Pagination:
list()returns acursor+hasMore; pass the cursor back asoptions.cursorfor the next page.
Test vs. live
One flag hints sandbox vs production to providers that distinguish them (most object stores don't — a bucket is a bucket):
new RadonStorage({ mode: "test", providers: { /* … */ } }); // default
new RadonStorage({ mode: "live", providers: { /* … */ } });Install size & lazy loading
The core (import { RadonStorage } from "@radonsdk/storage") contains zero provider code — only the interface, registry, SigV4 signer, HTTP/XML helpers, and the license/failover engines. Adapters are loaded on demand via dynamic import() the first time a provider is used, and each is emitted as its own subpath entry (@radonsdk/storage/providers/<slug>). A dev who configures only S3 + R2 never executes — or, under a code-splitting bundler, bundles — the other two dozen adapters. This is why one package can cover 25+ providers without bloating your install.
API surface
RadonStorage, createStorage, BaseProvider / S3CompatibleProvider, registerProvider / hasProvider / knownProviders, FREE_PROVIDERS / isProProvider / PRO_FEATURES, LicenseClient, runFailover, the SigV4Signer + HttpClient + signature/XML helpers (for BYO adapters), and every typed error (StorageError, LicenseRequiredError, UnsupportedOperationError, ObjectNotFoundError, AllProvidersFailedError, …). All types are exported.
License
MIT © Radon SDK. The SDK is MIT-licensed; the Pro tier requires a commercial license key at runtime for Pro providers and features. "# radonsdk-storage"
