Skip to content
s3nd
Menu

Architecture

An object in your bucket, a code in someone's hand.

s3nd is deliberately small. This page is the whole of it: what a transfer is, what a code is, the protocol between a server and its clients, and why the packages are split the way they are.

  1. Machine A
    • s3nd put ./report.pdf
    • or POST /api/transfers from your app
    • → K7QP2M4X
  2. Your bucket
    • drop/K7QP2M4X
    • 284 kB · expires in 1 h
    • S3, R2, MinIO, Scaleway, Wasabi
  3. Machine B
    • s3nd get k7qp-2m4x
    • or GET /api/transfers/:code/raw
    • → report.pdf, then rm
Eight characters read off one screen and typed into another. 40 bits

01A transfer

One object, one code, one expiry.

A file is stored as the bytes you gave, with the filename, the content type and the expiry in the object's metadata. Structured data is stored as a snapshot: a self-describing envelope, gzipped. Both sit under the code.

a file, as it lands in the bucket
drop/K7QP2M4X
  Content-Type:         application/pdf
  Content-Disposition:  attachment; filename="report.pdf"
  x-amz-meta-s3nd-kind:        file
  x-amz-meta-s3nd-expires-at:  2026-08-27T13:00:00.000Z
  Body:                 the bytes, untouched
a snapshot, as it lands in the bucket
{
  "s3nd": 1,              // envelope format, not your data's
  "app": "notes",
  "version": 3,           // your schema version
  "device": "Pixel 8",
  "createdAt": "2026-08-27T12:00:00.000Z",
  "expiresAt": "2026-08-27T13:00:00.000Z",
  "data": { /* whatever you passed */ }
}                          // gzipped, typically 5–10× smaller
expiry on read

A transfer past its expiry is never handed over, even if the object is still sitting in the bucket. An expired code answers the same NOT_FOUND as one that never existed, so nobody can probe which codes were used.

lifecycle rule

Deleting the object is your bucket's job, through a lifecycle rule on the prefix. s3nd doctor checks you have one, because a bucket quietly filling up with expired transfers is the most common way this goes wrong.

schema versions

A snapshot carries your schema version. Pass maxVersion on read and a snapshot from a newer build throws SNAPSHOT_TOO_NEW instead of landing in an app that will misread it.

02Sync codes

Forty bits that survive a phone call.

A code is the whole user experience of a transfer. It appears on one screen and someone types it into another, and everything about it is shaped by that.

Crockford base32

No I, L, O or U. The first three are what people misread; dropping the fourth keeps a random code from spelling something unfortunate.

Normalized on the way back

Separators dropped, case folded, and O read as zero only when there is no letter O to confuse it with. The repair happens in the browser, before any request.

Claimed with a conditional write

The server picks the code and writes with ifAbsent, so a collision fails loudly and retries with a fresh code instead of overwriting a stranger's transfer.

03The protocol

Four routes, one error format. Written down.

A browser cannot hold your S3 credentials, so when one takes part a server sits in the middle. The shape of that middle is a protocol, not whatever the handler happens to do.

relative to wherever you mounted it
POST   /                # create a transfer, get the code back
GET    /:code           # metadata; the state inline for a snapshot
GET    /:code/raw       # the bytes, or a 302 to a presigned URL
DELETE /:code           # burn it

# every error, same shape
{ "error": { "code": "NOT_FOUND", "message": "Unknown or expired code." } }
the client, in a browser
import { createTransferClient } from '@s3nd/protocol'

const transfers = createTransferClient({ baseUrl: '/api/transfers' })

const { code } = await transfers.createFile({ body: file, filename: file.name })
const meta = await transfers.read(typed)        // null when unknown or expired
const bytes = await transfers.readBytes(code)   // the file back
await transfers.remove(code)

A client works against any server that answers these routes, not only against createTransferHandler(). A server in Go or Rails works with every s3nd client. And the CLI pointed at --remote cannot tell which it is talking to, which is exactly why s3nd put works against your own deployment.

What you send decides what a transfer holds: a JSON body is a snapshot, any other content type is a file with its name in X-S3nd-Filename. Clients throw a TransferError carrying the error code; branch on the code, never on the message.

The transfer protocol, route by route

04Two writers

When two machines write, last-write-wins is data loss.

A one-shot transfer has a single writer. A per-user backup has two, and S3's default silently keeps whichever arrived last.

// Claim a fresh code: write only if nothing sits under it.
await store.putSnapshot(code, state, { ifAbsent: true })

// Rewrite a shared backup: fail if someone wrote since you read.
const current = await store.getSnapshot(`user-${userId}`)
await store.putSnapshot(`user-${userId}`, merged, { ifMatch: current?.etag })
// → PRECONDITION_FAILED when another device won. Read again, merge again.

Both options are plain S3 conditional headers. ifAbsent is how a fresh code is claimed; ifMatch is how a second device finds out it lost the race. They work on every provider that implements them, and the node example is where you find out whether yours does.

Two devices, one snapshot

05The packages

One constraint decides the split.

A browser must never end up with a storage client in its dependency tree. The protocol package is what both halves share, which is why it exists at all.

@s3nd/cli

s3nd
The binary

put, get, rm, doctor, init and config. One implementation, the protocol client, wired either to fetch or straight into the handler in-process.

s3nd

aws-sdk, protocol
The primitive

Files, snapshots, conditional writes and the transfer handler. The only package that holds credentials, so the only one that runs on a server.

@s3nd/protocol

nanoid
The contract

The wire format, a fetch-based client, and the sync codes. Nothing here imports a storage client, which is what lets a browser share it.

@s3nd/react

protocol, react (peer)
The hooks

Send, receive, and a code input. Depends on the protocol and never on S3, so no path from your bundle reaches the AWS SDK.

Server runtime
Node 20 or later, what the AWS SDK v3 requires
Handler
Request in, Response out: Next.js, Hono, Bun.serve, Deno, workers
Browser packages
fetch and nothing else: browser, worker, React Native, Deno
Tests
Offline, against an in-memory S3 that honours conditional headers

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