Skip to content

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

sh
pnpm add @nwire/storage @nwire/storage-s3

Local development with MinIO

The repo's docker-compose.yml ships MinIO pre-configured. Start it:

sh
nwire infra up

That gives you:

  • S3 endpoint at http://localhost:9000
  • Web console at http://localhost:9001 (login: minioadmin / minioadmin)
  • A bucket called nwire-dev auto-created by the minio-bootstrap init container

Wire it into your app

ts
import { createApp }      from "@nwire/app"
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 = createApp({
  appName: "my-app",
  plugins: [storagePlugin({ storage })],
})

Use it from a route handler

ts
import { post } from "@nwire/wires/http"
import type { Storage } from "@nwire/storage"
import { z } from "zod"

app.wire(
  post("/avatars", { body: z.object({ bytes: z.string(), contentType: z.string() }) }),
  async (input, ctx) => {
    const storage = ctx.resolve<Storage>("storage")
    const userId = ctx.envelope.userId ?? "anon"
    const key = `avatars/${userId}.png`
    await storage.put(key, input.bytes, { contentType: input.contentType })
    const url = await storage.url(key, { expiresInSeconds: 3600 })
    return { url }
  },
)

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:

ts
const storage = s3Storage({
  bucket: process.env.S3_BUCKET!,
  region: process.env.AWS_REGION ?? "us-east-1",
})

Cloudflare R2

ts
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):

ts
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):

ts
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, /ready 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:

  1. Translates the narrow Storage contract to S3 commands
  2. Maps NotFound errors to StorageObjectNotFoundError (transports turn this into a clean 404)
  3. Provides health + shutdown lifecycle
  4. 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, ... }).

MIT licensed.