@beignet/provider-storage-s3
Beignet is experimental alpha software. The
0.0.xpackage line is for early evaluation, and APIs may change between releases while the framework settles.
S3-compatible object storage provider for Beignet.
The provider installs the app-facing ctx.ports.storage port. Use it for
production object storage on AWS S3, Cloudflare R2, MinIO, Backblaze B2,
DigitalOcean Spaces, or another S3-compatible backend.
Install
bun add @beignet/provider-storage-s3 @beignet/core @aws-sdk/client-s3 @aws-sdk/s3-request-presignerProvider setup
import { s3StorageProvider } from "@beignet/provider-storage-s3";
import { createServer } from "@beignet/core/server";
const server = await createServer({
ports: basePorts,
providers: [s3StorageProvider],
context: ({ ports }) => ({ ports }),
routes,
});Environment variables:
| Variable | Description |
|---|---|
STORAGE_S3_BUCKET |
Bucket name. |
STORAGE_S3_REGION |
Region. Defaults to us-east-1. Use auto for Cloudflare R2. |
STORAGE_S3_ENDPOINT |
Optional S3-compatible endpoint. Required for R2, MinIO, Spaces, B2, and similar services. |
STORAGE_S3_ACCESS_KEY_ID |
Optional static access key. |
STORAGE_S3_SECRET_ACCESS_KEY |
Optional static secret key. |
STORAGE_S3_SESSION_TOKEN |
Optional static session token. |
STORAGE_S3_PUBLIC_BASE_URL |
Optional base URL returned by publicUrl(...) for public objects. |
STORAGE_S3_FORCE_PATH_STYLE |
Optional true or false path-style addressing toggle. |
STORAGE_S3_KEY_PREFIX |
Optional prefix for every object key written by this app. |
STORAGE_S3_BUCKET is required unless you pass bucket directly.
beignet doctor --strict checks that installed S3 storage providers are
registered in server/providers.ts and that the bucket requirement is present
in app env examples or config when the env-backed provider is used.
AWS S3
STORAGE_S3_BUCKET=my-app-assets
STORAGE_S3_REGION=us-east-1
STORAGE_S3_PUBLIC_BASE_URL=https://cdn.example.comWhen credentials are omitted, the AWS SDK uses its normal credential provider chain.
Cloudflare R2
STORAGE_S3_BUCKET=my-app-assets
STORAGE_S3_REGION=auto
STORAGE_S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
STORAGE_S3_ACCESS_KEY_ID=...
STORAGE_S3_SECRET_ACCESS_KEY=...
STORAGE_S3_PUBLIC_BASE_URL=https://assets.example.comR2 is S3-compatible, but not every S3 feature exists on every compatible
service. This provider only relies on object put, get, head, and
delete.
Client lifecycle
When the provider creates the AWS SDK S3Client itself, its stop hook
destroys that client on server shutdown to release keep-alive sockets.
Clients you inject — through createClient on createS3StorageProvider(...)
or client on createS3Storage(...) and createS3UploadSigner(...) — are
caller-owned. Beignet never destroys them, so close them yourself when your
app shuts down.
Request cost
Some port operations need more than one S3 request to honor the StoragePort
contract:
| Operation | S3 requests |
|---|---|
put(...) |
PutObject + HeadObject |
get(...) |
GetObject |
stat(...) |
HeadObject |
exists(...) |
HeadObject |
delete(...) |
HeadObject + DeleteObject |
publicUrl(...) |
HeadObject, or none when publicBaseUrl is unset |
put(...)stats after writing so the returnedStorageObjectreflects what S3 actually stored instead of echoing the inputs.delete(...)stats before deleting because S3DeleteObjectdoes not report whether the object existed, and the port returns that boolean.publicUrl(...)stats to check the stored visibility, and skips S3 entirely when no public base URL is configured.
Direct port factory
import { createS3Storage } from "@beignet/provider-storage-s3";
const storage = createS3Storage({
bucket: "my-app-assets",
region: "auto",
endpoint: "https://<account-id>.r2.cloudflarestorage.com",
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
publicBaseUrl: "https://assets.example.com",
});The same StoragePort works with local files, memory tests, and
S3-compatible object stores:
await ctx.ports.storage.put("avatars/user_123.png", avatarBytes, {
contentType: "image/png",
visibility: "public",
});
const object = await ctx.ports.storage.get("avatars/user_123.png");
const url = await ctx.ports.storage.publicUrl("avatars/user_123.png");Direct upload signer
Use createS3UploadSigner(...) with @beignet/core/uploads when browsers
should upload directly to S3 or an S3-compatible service:
import { createUploadRouter } from "@beignet/core/uploads";
import { createS3UploadSigner } from "@beignet/provider-storage-s3";
const uploadRouter = createUploadRouter({
uploads: postUploads,
ctx: () => server.createContextFromNext(),
storage: server.ports.storage,
signer: createS3UploadSigner({
bucket: "my-app-assets",
region: "auto",
endpoint: "https://<account-id>.r2.cloudflarestorage.com",
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
keyPrefix: "production",
}),
});The signer returns presigned PUT URLs with the content type, cache control, and Beignet storage metadata headers the browser must send.
Visibility
visibility is stored as reserved object metadata so publicUrl(...) can
return URLs only for objects written with visibility: "public". The provider
does not set S3 ACLs. Configure bucket policies, R2 public buckets, or a CDN
outside the provider when objects should be publicly reachable.
The reserved metadata key is beignet-visibility. It is hidden from
StorageObject.metadata.
Escape hatch
The provider also installs ctx.ports.s3Storage for S3-specific operations that
do not belong in StoragePort:
import { ListObjectsV2Command } from "@aws-sdk/client-s3";
const s3Key = ctx.ports.s3Storage.objectKey("exports/report.csv");
const s3Prefix = ctx.ports.s3Storage.objectPrefix("exports");
await ctx.ports.s3Storage.client.send(
new ListObjectsV2Command({
Bucket: ctx.ports.s3Storage.bucket,
Prefix: s3Prefix,
}),
);
const health = await ctx.ports.s3Storage.checkHealth();Use objectKey(...) when direct S3 calls need to address objects written
through ctx.ports.storage. Use objectPrefix(...) for list operations. Both
helpers apply the configured STORAGE_S3_KEY_PREFIX. Use checkHealth() from
app-owned readiness endpoints when the bucket policy allows HeadBucket.
Devtools
When ctx.ports.devtools is installed, the provider records storage
operations under the storage watcher and direct upload signing under the
uploads watcher. Events include operation name, key, bucket, duration, object
size, visibility, and whether a lookup hit. Object bodies and metadata values
are never recorded.
Failure behavior
The env-backed provider throws during startup when STORAGE_S3_BUCKET is
missing. Storage operations surface AWS SDK or compatible-service errors. A
missing object maps to null/false where the stable StoragePort expects
that shape; unexpected service failures throw.
checkHealth() returns a structured unhealthy result instead of throwing when
the bucket cannot be reached or the credentials cannot perform HeadBucket.
Local and tests
Use @beignet/provider-storage-local, a memory/fake StoragePort, or a local
S3-compatible service such as MinIO for tests. Use the direct factory with an
injected client for provider adapter tests.
Deployment notes
Configure credentials through the runtime's normal secret mechanism or the AWS
SDK credential chain. Set STORAGE_S3_KEY_PREFIX per app/environment when one
bucket is shared, and configure bucket policy/CDN behavior outside Beignet for
public objects.
License
MIT