Storage: S3 (and MinIO, R2, B2, …)
Object storage via the S3 API. Same adapter, configured differently per provider.
@nwire/storage-s3 wraps the AWS SDK. It works with anything that speaks the S3 API, which in practice is most of the object-storage market.
Install
pnpm add @nwire/storage @nwire/storage-s3Local development with MinIO
The repo's docker-compose.yml ships MinIO pre-configured. Start it:
nwire infra upThat gives you:
- S3 endpoint at
http://localhost:9000 - Web console at
http://localhost:9001(login:minioadmin/minioadmin) - A bucket called
nwire-devauto-created by theminio-bootstrapinit container
Wire it into your app
import { defineApp } from "@nwire/forge"
import { storagePlugin } from "@nwire/storage"
import { s3Storage } from "@nwire/storage-s3"
const storage = s3Storage({
bucket: "nwire-dev",
endpoint: "http://localhost:9000",
forcePathStyle: true, // required for MinIO
region: "us-east-1", // any value; MinIO ignores it
credentials: {
accessKeyId: "minioadmin",
secretAccessKey: "minioadmin",
},
})
export const app = defineApp("my-app", {
modules: [/* ... */],
plugins: [storagePlugin({ storage })],
})Use it from a route binding
import { httpInterface, post } from "@nwire/http"
import { response } from "@nwire/forge"
import type { Storage } from "@nwire/storage"
import { z } from "zod"
httpInterface()
.wire(
post("/avatars", { body: z.object({ bytes: z.string(), contentType: z.string() }) }),
async ({ input, resolve, envelope }) => {
const storage = resolve<Storage>("storage")
const key = `avatars/${envelope.user!.id}.png`
await storage.put(key, input.bytes, { contentType: input.contentType })
const url = await storage.url(key, { expiresInSeconds: 3600 })
return response.ok({ url })
},
)
.run()Switch to AWS S3 in production
Strip the MinIO-specific bits — endpoint, forcePathStyle, and the static credentials. The default credential chain (env vars, IAM role) takes over:
const storage = s3Storage({
bucket: process.env.S3_BUCKET!,
region: process.env.AWS_REGION ?? "us-east-1",
})Cloudflare R2
const storage = s3Storage({
bucket: "my-r2-bucket",
endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`,
region: "auto",
credentials: { accessKeyId: R2_KEY, secretAccessKey: R2_SECRET },
})Backblaze B2 / Wasabi / DO Spaces
Same pattern — provide their endpoint URL and forcePathStyle: true if their docs ask for it.
Presigned URLs
For client-direct uploads (avoid streaming bytes through your server):
const uploadUrl = await storage.url("uploads/" + crypto.randomUUID(), {
method: "put",
expiresInSeconds: 300,
contentType: "image/png",
})
// Send `uploadUrl` to the browser; client PUTs the file directly.For client-direct downloads (avoid serving bytes through your server):
const downloadUrl = await storage.url("reports/2025.pdf", {
expiresInSeconds: 60,
})Health checks
The adapter calls HeadBucket on every readiness probe. If the bucket disappears or credentials rotate, /readyz will fail fast and Kubernetes will stop routing traffic.
Why "glue, not wrap"
We don't reinvent the S3 SDK — the adapter is a thin layer that:
- Translates the narrow Storage contract to S3 commands
- Maps NotFound errors to
StorageObjectNotFoundError(transports turn this into a clean 404) - Provides health + shutdown lifecycle
- Hands you presigned URLs without you having to wire up the presigner yourself
Everything else you'd want from S3 — multipart uploads, lifecycle policies, replication — you do via the real SDK by passing your own client to s3Storage({ client, ... }).