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<Object>, Option<String>), 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 areVec<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 aString. The caller picks how to use it.is_healthy— defaulttrue; 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:
- Leaf crate hygiene.
bext-plugin-apidepends only onserdeandserde_json. PuttingBytesin the trait would dragbytesinto every WASM guest crate that links against the API, for an ergonomic win that doesn't outweigh the dep cost. - Flat WASM ABI. The guest / host boundary passes plain byte buffers anyway, so there is no cheap-clone advantage on the wire.
- Callers can wrap. Anyone who wants
Byteson the outside can doBytes::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<Object>, Option<String>), 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
futuresinto a leaf crate.bext-plugin-apiwould otherwise needfutures-corejust to nameStream. - 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 anasync_stream::stream!(async) overlist_pageis 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, regionauto. - 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.
If you need to serve a large file directly to the client without buffering it in your handler, use a presigned Get URL (PresignOp::Get) and redirect the client there — get materializes the entire object into a Vec<u8> in the plugin process, which is not suitable for multi-gigabyte objects.
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
multipartsubmodule with its own types), because the shape is too different from a single-shotputto squeeze into one method without compromises. - Streaming reads of very large objects.
getmaterializes the whole body into aVec<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-s3reference plugin lives incrates/bext-impls/bext-storage-s3and is the recommended starting point for anyone porting an existing S3 / R2 / MinIO / B2 codebase onto the capability shape.