Skip to content

File storage

@nwire/storage is a tiny contract — put, get, delete, url, list. Two adapters ship: @nwire/storage-s3 (AWS S3 and anything S3-compatible — MinIO, R2, B2, DO Spaces, …) and @nwire/storage-fs (local filesystem, useful in dev and for tests).

Handler code stays the same across all of them. Swap the adapter in the wire file; the handler never knows.

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.

Glue, not wrap

The adapter doesn't reinvent the S3 SDK. It's a thin layer that:

  1. Translates the narrow Storage contract to S3 commands.
  2. Maps NotFound errors to StorageObjectNotFoundError (transports turn these into a clean 404).
  3. Owns the readiness probe + shutdown lifecycle.
  4. Gives you presigned URLs without wiring 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.