Skip to content
s3nd
Menu

s3nd

Files and snapshots, in your bucket.

A small file API over object storage, a transfer handler that is one route file, and snapshots for structured state. The only package that holds credentials, so the only one that runs on your server.

npm install s3nd
import { createBucket, createTransferHandler } from 's3nd'

const store = createBucket({ bucket: 'drop' })

// A drop box on your own domain, in one route file.
export const { GET, POST, DELETE } = createTransferHandler({
  bucket: store,
  expiresIn: 24 * 3600,
  authorize: (request) => request.headers.get('authorization') === `Bearer ${process.env.TOKEN}`,
})

01The file API

Five verbs over your bucket.

Strings, buffers, Blobs and streams are all accepted. Keys round-trip: what upload() returns is what you hand back to get(), getUrl() and delete(). The configured prefix is an internal namespace.

await store.upload(file)     // → { key, path, url?, size?, etag?, contentType }
await store.put(id, file)    // one file per identifier: create or replace
await store.get(id)          // → the file back, or null
await store.getUrl(id)       // → public or presigned URL
await store.delete(id)       // → void

store.client                 // the plain S3Client, for anything else

getUrl() returns a presigned URL by default, or an unsigned one when a publicUrl is configured, with a download option that sets the filename the browser saves.

A stream needs a contentLength, because a single PutObject cannot use chunked encoding. Set maxSize and an oversized body is refused before anything reaches the network.

Anything the package does not wrap is one command away through store.client, the plain S3Client.

The API reference

02The handler

A drop box on your domain, in one route file.

createTransferHandler() serves the four-route protocol: create, read, download, burn. It takes a Request and returns a Response, so it is a Next route, a Hono route, Bun.serve or a worker without an adapter.

Next.js App Router
// app/api/transfers/[[...route]]/route.ts
import { createBucket, createTransferHandler } from 's3nd'

export const { GET, POST, DELETE } = createTransferHandler({
  bucket: createBucket({ bucket: 'drop' }),
  expiresIn: 24 * 3600,
  raw: 'redirect', // downloads 302 to a presigned URL
  authorize: (request) => request.headers.get('authorization') === `Bearer ${process.env.TOKEN}`,
})
Hono
import { Hono } from 'hono'
import { createBucket, createTransferHandler } from 's3nd'

const transfers = createTransferHandler({ bucket: createBucket(), basePath: '/api/transfers' })

const app = new Hono()
app.all('/api/transfers', (c) => transfers(c.req.raw))
app.all('/api/transfers/*', (c) => transfers(c.req.raw))
Bun.serve
import { createBucket, createTransferHandler } from 's3nd'

const transfers = createTransferHandler({ bucket: createBucket(), basePath: '/api/transfers' })

Bun.serve({
  fetch(request) {
    if (new URL(request.url).pathname.startsWith('/api/transfers')) return transfers(request)
    return new Response('Not found', { status: 404 })
  },
})

Every route is public unless you pass authorize: fine for a personal drop box behind a proxy, not fine for anything else. Return false for a plain 401 or a Response to answer with your own. With raw: 'redirect' a download answers 302 with a presigned URL, so the bytes never transit your server twice.

03Snapshots

Structured state, with a restore that is safe rather than hopeful.

putSnapshot() wraps your value in an envelope with your app name, schema version, device and expiry, then gzips it. getSnapshot() reads the envelope back and refuses what it should.

const code = store.codes.create() // "K7QP2M4X"
await store.putSnapshot(code, state, { app: 'notes', version: 3, expiresIn: 3600, ifAbsent: true })

const snapshot = await store.getSnapshot(store.codes.normalize(typed), { maxVersion: 3 })
snapshot?.data      // the state, or null when unknown or expired
snapshot?.createdAt // what to show before replacing anything
snapshot?.device

null when expired

An expired snapshot is never handed over, even if the object is still in the bucket. The receiving device does not need to tell "never existed" from "expired".

SNAPSHOT_TOO_NEW

Pass maxVersion and a snapshot written by a newer build throws instead of landing in an app that will misread it.

04Conditional writes

No silent overwrite, on any provider that implements them.

Both options are plain S3 conditional headers, and both fail before anything is replaced.

// Claim a fresh code: write only if nothing is stored under it yet.
await store.put(code, file, { ifAbsent: true })

// Rewrite a shared object: fail if someone else wrote since you read.
const current = await store.getSnapshot(`user-${userId}`)
await store.putSnapshot(`user-${userId}`, merged, { ifMatch: current?.etag })

ifAbsent is how a freshly generated code is claimed without a chance of trampling one already in use. The handler retries with a fresh code on the rare collision.

ifMatch is how a second device finds out it lost the race. It gets PRECONDITION_FAILED, reads again, and merges, which is application code because only your app knows what a merge means.

Two devices, one snapshot

05Errors

Everything throws a S3ndError with a stable code.

Failures that can be caught locally, a bad code, an oversized body, unserializable data, are raised before anything reaches the network.

import { isS3ndError } from 's3nd'

try {
  await store.upload(body, { filename })
} catch (error) {
  if (isS3ndError(error) && error.code === 'FILE_TOO_LARGE') {
    return Response.json({ error: 'Too large to transfer in one piece' }, { status: 413 })
  }
  throw error
}
INVALID_SYNC_CODE
Empty, or characters outside the alphabet
FILE_TOO_LARGE
Body above the configured maxSize
PRECONDITION_FAILED
An ifMatch or ifAbsent write lost the race
SNAPSHOT_TOO_NEW
Schema version above the maxVersion given
INVALID_KEY / INVALID_BODY
A key or a body type the bucket cannot take
UPLOAD_FAILED / GET_FAILED / …
S3 rejected the request; the original error is in cause

06Configuration

Every option, and the environment variable behind it.

createBucket() with no arguments works once S3_BUCKET and the usual AWS variables are set. With an endpoint, region defaults to auto and path-style addressing turns on, which is what R2, MinIO and Scaleway expect.

createBucket({
  bucket: 'drop',               // or S3ND_BUCKET / S3_BUCKET
  region: 'eu-west-3',          // or S3ND_REGION / AWS_REGION
  credentials: { … },           // omit for the AWS provider chain
  endpoint: 'https://…',        // R2, MinIO, Scaleway, Wasabi — or S3ND_ENDPOINT
  prefix: 'drop',               // internal namespace
  maxSize: 4 * 1024 * 1024,     // reject before any network call
  syncCode: { length: 8 },      // the shape of store.codes
})

Through your server, a transfer is bound by your runtime's request limit: 4.5 MB on Vercel functions, 6 MB on Lambda. Set maxSize just under it and an oversized upload costs a comparison instead of a truncated request.

createBucket() is cheap: the underlying client is built on the first request, so calling it at module scope is fine.

Put a file. Hand over the code.

Point it at the bucket you already pay for. Nothing to deploy, nothing to sign up for, nothing in the middle.

npm install -g @s3nd/clis3nd init --provider r2 --bucket drops3nd put ./anything.zip