# The library

> The s3nd Node package: a file API over your bucket, a transfer handler that is one route file, snapshots with a self-describing envelope, conditional writes and stable error codes. Works with AWS S3, Cloudflare R2, MinIO, Scaleway and Wasabi.

Canonical: https://www.s3nd.sh/library · Markdown: https://www.s3nd.sh/library.md · Français: https://www.s3nd.sh/fr/library

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.

```sh
npm install @s3nd/core
```
```ts
import { createBucket, createTransferHandler } from '@s3nd/core'

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}`,
})
```

## 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.

```ts
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`.

## 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**
```ts
// app/api/transfers/[[...route]]/route.ts
import { createBucket, createTransferHandler } from '@s3nd/core'

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**
```ts
import { Hono } from 'hono'
import { createBucket, createTransferHandler } from '@s3nd/core'

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**
```ts
import { createBucket, createTransferHandler } from '@s3nd/core'

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.

## 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.

```ts
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.

## No silent overwrite, on any provider that implements them.
Both options are plain S3 conditional headers, and both fail before anything is replaced.

```ts
// 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.

## 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.

```ts
import { isS3ndError } from '@s3nd/core'

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

## 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.

```ts
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.

