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 { 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

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

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

  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.