Storage Client
The StorageClient capability is the runtime-side interface for any blob / object store. A StorageClientPlugin gets bytes by key, puts bytes at a key, deletes a key, lists keys under a prefix, and mints presigned URLs. That is the entire shape — anything that looks like "named blob in a flat keyspace" fits behind it, and application code calling into the capability never knows whether the backend is S3, R2, MinIO, a local filesystem, or an in-memory test double.
The shape is deliberately narrow. Versioning beyond a single etag, consistency tiers, multipart upload, and replication are all backend-specific concerns that stay in the plugin, not in the shared trait.
The trait
pub trait StorageClientPlugin: Send + Sync {
fn name(&self) -> &str;
fn get(&self, key: &str) -> Result<Vec<u8>, StorageError>;
fn put(&self, key: &str, body: Vec<u8>, opts: PutOpts) -> Result<(), StorageError>;
fn delete(&self, key: &str) -> Result<(), StorageError>;
fn list_page(
&self,
prefix: &str,
continuation_token: Option<&str>,
) -> Result<(Vec, Option), StorageError>;
fn presigned_url(
&self,
key: &str,
op: PresignOp,
ttl_secs: u64,
) -> Result<String, StorageError>;
fn is_healthy(&self) -> bool { true }
}
- name() — a short identifier shown in logs, metrics, and the TUI.
- get / put / delete — key-level operations. Payloads are Vec<u8>, deliberately. See the note on payloads below.
- list_page — paginated listing. See the note on list pagination below.
- presigned_url — returns a signed URL as a String. The caller picks how to use it.
- is_healthy — default true; backends with a long-lived client should override to ping the service.
Payloads are Vec<u8>
Early drafts used bytes::Bytes for get and put. We moved back to Vec<u8> for three reasons:
1. Leaf crate hygiene. bext-plugin-api depends only on serde and serde_json. Putting Bytes in the trait would drag bytes into every WASM guest crate that links against the API, for an ergonomic win that doesn't outweigh the dep cost.
2. Flat WASM ABI. The guest / host boundary passes plain byte buffers anyway, so there is no cheap-clone advantage on the wire.
3. Callers can wrap. Anyone who wants Bytes on the outside can do Bytes::from(vec) in one line, and pay the conversion cost only where they care.
Native backends that hold an aws-sdk / object_store / local-fs client internally can of course use Bytes on the inside; the trait surface just asks them to hand out plain vectors at the boundary.
List is paginated, not streamed
Every object store exposes listing as a paginated endpoint with a continuation token — S3, R2, GCS, Azure Blob, they all look the same from the outside. We expose that shape directly:
fn list_page(
&self,
prefix: &str,
continuation_token: Option<&str>,
) -> Result<(Vec, Option), StorageError>;
The returned tuple is (objects_on_this_page, next_token). If next_token is Some, pass it back on the next call. None means "no more pages". An empty first page is a valid (non-error) response — NotFound is reserved for keys that don't exist, not for prefixes that have no matches yet.
We chose this shape over a BoxStream<'_, Object> because:
- It keeps the trait synchronous, matching every other E1/E2 capability (Session, Auth, Mailer, Tracer, Scheduled, Webhook, FeatureFlag, I18n, Locking).
- It avoids dragging futures into a leaf crate. bext-plugin-api would otherwise need futures-core just to name Stream.
- It matches how real backends work. Every object store's native list API is paginated. A stream wrapper was the exception, not the rule.
- Callers who want a stream can build one on top in two lines. A std::iter::from_fn (sync) or an async_stream::stream! (async) over list_page is trivial.
Backends that happen to have a native stream shape inside can still use it internally and slice into pages at the trait boundary.
Error classification
Errors are a flat enum with four load-bearing variants:
| Variant | HTTP (when surfaced upstream) | When the backend returns it |
|---|---|---|
NotFound |
404 | Key does not exist. Returned by get and presigned_url for missing keys. |
AccessDenied |
403 | Credentials missing, wrong, or allowed-but-not-for-this-key. |
QuotaExceeded |
413 | Object size, per-bucket ceiling, or request-rate quota was exceeded. |
Backend(String) |
500 | Anything else: network, disk, provider 5xx, malformed response, SDK error. Message is for logs. |
Callers branch on the variant to decide what to do. A typical upload handler looks like:
match storage.put(key, bytes, opts) {
Ok(()) => Ok(204),
Err(StorageError::QuotaExceeded) => Err((413, "too large")),
Err(StorageError::AccessDenied) => Err((403, "forbidden")),
Err(StorageError::NotFound) => Err((404, "gone")),
Err(StorageError::Backend(msg)) => {
tracing::error!("storage backend error: {msg}");
Err((500, "internal"))
}
}
Plugins are expected to classify — not paraphrase — their backend's errors. @bext/storage-s3 maps NoSuchKey → NotFound, HTTP 403 → AccessDenied, and EntityTooLarge → QuotaExceeded, with everything else falling through to Backend(msg).
Presigned URLs
presigned_url(key, op, ttl_secs) mints a short-lived signed URL for the given key. PresignOp is a narrow enum:
| Variant | Meaning |
|---|---|
Get |
Signed download URL. The holder can fetch the object for ttl_secs. |
Put |
Signed upload URL. The holder can upload bytes at the key for ttl_secs. |
Only those two, deliberately. Delete and Head can be added later if a concrete caller needs one; shipping them early would lock in a shape we haven't stress-tested. Backends that cannot mint presigned URLs (a pure-filesystem plugin, for example) should return Backend("presigned URLs not supported") rather than panicking.
The return type is String, not url::Url, for the same leaf-crate reason — the trait crate does not pull in url. Callers parse themselves if they want.
S3-compatible backends via @bext/storage-s3
The first reference implementation, @bext/storage-s3, is a thin wrapper around the AWS SDK v1 aws-sdk-s3 client. It works against real AWS S3 and against any provider that speaks the S3 API — the backend is selected purely by overriding the endpoint URL at construction time:
- AWS S3 — no override, default region resolved from the environment.
- Cloudflare R2 — https://<account>.r2.cloudflarestorage.com, region auto.
- MinIO — http://minio.local:9000 (or wherever your MinIO lives).
- Backblaze B2 — https://s3.<region>.backblazeb2.com.
The plugin turns on path-style addressing automatically when an endpoint override is set, because every non-AWS provider in that list requires it and AWS tolerates it. Operators don't have to learn another flag.
Inside the plugin, each trait method is synchronous; the async AWS SDK client is driven through either an injected tokio handle (when the host already runs tokio) or an owned single-thread runtime. Same pattern as @bext/mailer-ses.
Example: bext.config.toml
# dev: local MinIO, single endpoint override
[storage]
provider = "s3"
bucket = "dev-uploads"
endpoint = "http://minio.local:9000"
region = "us-east-1"
# prod: Cloudflare R2, production bucket
[storage]
provider = "s3"
bucket = "prod-uploads"
endpoint = "https://abc123.r2.cloudflarestorage.com"
region = "auto"
# credentials come from env (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)
Switching backends is a config change. Application code calling storage.put(...) stays exactly the same; the plugin behind the storage capability is whichever one the operator configured.
When StorageClient is the wrong shape
StorageClient is for blob-shaped work — opaque bytes behind a key, indexed by prefix. It is not for:
- Structured queries. If you need WHERE tag = 'foo' ORDER BY created_at, that is a database or a search index, not a blob store.
- Transactional mutations across keys. Object stores are last-write-wins; multi-key atomic writes don't exist in this shape. Use a real database.
- Multipart upload / resumable upload flows. These will eventually have a dedicated extension on the trait (likely a multipart submodule with its own types), because the shape is too different from a single-shot put to squeeze into one method without compromises.
- Streaming reads of very large objects. get materializes the whole body into a Vec<u8>. Callers who need to stream a 10GB export to the client directly should use the backend-specific plugin API until a streaming extension lands.
If the thing you're storing is "a reasonable-sized file the user uploaded" or "a serialized snapshot for a batch job", StorageClient is the right shape. If it is "a large-object pipeline with partial reads and resumable writes", wait for the multipart extension.
Where to go next
- Capabilities overview covers the promotion ladder and the other E1/E2 foundational capabilities.
- The @bext/storage-s3 reference plugin lives in crates/bext-impls/bext-storage-s3 and is the recommended starting point for anyone porting an existing S3 / R2 / MinIO / B2 codebase onto the capability shape.