s3nd.shdocs

Quick start

Four ways in, depending on what you are building. Pick yours; each one gets you to a working code in a few minutes.

s3nd is four packages, and the right one depends on where the transfer starts. Pick the line that sounds like what you are building:

You are buildingInstallRuns onNeeds a server
A terminal workflow, CInpm install -g @s3nd/cliyour machinesno
A React app@s3nd/react + @s3nd/corebrowser + your serveryes, yours
Server-side code@s3nd/coreNode 20+, Bun, Deno, workersit is one
A client in another runtime@s3nd/protocolanything with fetchyes, any s3nd

The first three need a bucket you can write to: AWS S3, Cloudflare R2, MinIO, Scaleway, Wasabi or any other S3-compatible storage. Providers says what each one wants. The last one only needs the URL of a server that already has one.

From a terminal

Install the CLI

npm install -g @s3nd/cli

Answer a few questions

s3nd setup

It asks where transfers are stored, the bucket and the keys, with where to find each one. The answers go to ~/.config/s3nd/config.json, readable by you only and used from any directory, and it ends by writing, reading and deleting a probe object to prove the bucket works.

Skip this step if you like: the first command that needs a bucket offers to run it.

Send, then receive

s3nd put ./report.pdf     # prints a code: K7QP2M4X
s3nd get k7qp-2m4x        # on the other machine; case and dashes do not matter

Next: the CLI reference, or Without a server for the whole R2 setup, CI included.

In a React app

A browser cannot hold your bucket's keys, so this path has two halves: four routes on your server, and hooks in the browser that talk to them. The AWS SDK never reaches the client bundle.

Install both halves

npm install @s3nd/core @s3nd/react

Mount the routes on your server

app/api/transfers/[[...route]]/route.ts
import { createBucket, createTransferHandler } from '@s3nd/core'

export const { GET, POST, DELETE } = createTransferHandler({
  bucket: createBucket({ bucket: process.env.S3_BUCKET, region: process.env.AWS_REGION }),
  app: 'notes',
  expiresIn: 3600,
})

The handler takes a Request and returns a Response, so the same line works in Hono, Bun or a worker. Every route is public until you pass authorize: Setting up a server.

Point the provider at them

app/providers.tsx
'use client'

import { S3ndProvider } from '@s3nd/react'

export function Providers({ children }: { children: React.ReactNode }) {
  return <S3ndProvider baseUrl="/api/transfers">{children}</S3ndProvider>
}

Send on one device, receive on the other

import { useSendTransfer } from '@s3nd/react'

export function MoveToAnotherDevice() {
  const { send, transfer, isPending } = useSendTransfer()

  return (
    <>
      <button onClick={() => send(exportState())} disabled={isPending}>
        Move to another device
      </button>
      {transfer && <p>Type this on the other device: {transfer.code}</p>}
    </>
  )
}

exportState and importState are yours: only your app knows its own storage. The IndexedDB example has a complete pair.

Next: React for every hook and option, The transfer protocol for what the routes do.

From your server

When your own routes decide what gets stored, call the bucket directly. No protocol, no client package: a code, a snapshot, and your own two endpoints.

Install the core

npm install @s3nd/core

Credentials come from the options, the usual AWS_* variables, or the platform's role: Installation.

Create the bucket once

lib/store.ts
import { createBucket } from '@s3nd/core'

export const store = createBucket({
  bucket: process.env.S3_BUCKET,
  region: process.env.AWS_REGION,
  prefix: 'snapshots',
  maxSize: 4 * 1024 * 1024,
})

export const SCHEMA_VERSION = 3

createBucket() is cheap, since the S3Client is built on the first request, so module scope is fine. maxSize below your platform's request limit turns an oversized snapshot into a clean 413.

Store a snapshot under a fresh code

app/api/sync/route.ts
import { store, SCHEMA_VERSION } from '@/lib/store'

export async function POST(request: Request) {
  const code = store.codes.create()

  await store.putSnapshot(code, await request.json(), {
    app: 'notes',
    version: SCHEMA_VERSION,
    expiresIn: 60 * 60,
    ifAbsent: true,
  })

  return Response.json({ code })
}

expiresIn keeps a code from outliving the transfer: it is a bearer token, and a short life is most of its security. ifAbsent makes a collision fail loudly instead of overwriting a stranger's snapshot.

Hand it back on the other device

app/api/sync/[code]/route.ts
import { store, SCHEMA_VERSION } from '@/lib/store'

export async function GET(_request: Request, { params }: { params: Promise<{ code: string }> }) {
  const { code } = await params
  const snapshot = await store.getSnapshot(store.codes.normalize(code), { maxVersion: SCHEMA_VERSION })

  if (!snapshot) return Response.json({ error: 'Unknown or expired code' }, { status: 404 })

  return Response.json({ data: snapshot.data, createdAt: snapshot.createdAt })
}

store.codes.normalize() is what makes a typed code work: separators dropped, case folded, the characters people misread repaired. Call it on user input, always. maxVersion makes a snapshot from a newer build throw rather than land in an app that would misread it.

Next: Snapshots for what is in the envelope, Sync codes for how guessable a code really is, Two devices, one snapshot for when both sides write.

From another runtime

Anything with fetch can talk to a s3nd server, whether createTransferHandler() runs it or something written in Go does:

npm install @s3nd/protocol
import { createTransferClient } from '@s3nd/protocol'

const transfers = createTransferClient({ baseUrl: 'https://drop.example.com/api/transfers' })

const { code } = await transfers.createSnapshot({ data: state, version: 3 })
const incoming = await transfers.read(typedCode) // null when unknown or expired

Its whole dependency tree is nanoid; no path from it reaches the AWS SDK, so it bundles for React Native or a worker as is. Next: The transfer protocol.

On this page