# La bibliothèque

> Le package Node s3nd : une API de fichiers sur votre bucket, un handler de transfert qui tient dans un fichier de route, des snapshots avec une enveloppe auto-descriptive, des écritures conditionnelles et des codes d’erreur stables. Compatible AWS S3, Cloudflare R2, MinIO, Scaleway et Wasabi.

Canonical: https://s3nd.sh/fr/library · Markdown: https://s3nd.sh/fr/library.md · English: https://s3nd.sh/library

Une petite API de fichiers sur du stockage objet, un handler de transfert qui tient dans un fichier de route, et des snapshots pour l’état structuré. Le seul package qui détient des identifiants, donc le seul qui tourne sur votre serveur.

```sh
npm install s3nd
```
```ts
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}`,
})
```

## Cinq verbes sur votre bucket.
Chaînes, buffers, Blobs et streams sont tous acceptés. Les clés font l’aller-retour : ce que upload() renvoie est ce que vous redonnez à get(), getUrl() et delete(). Le préfixe configuré est un espace de noms interne.

```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()` renvoie une URL présignée par défaut, ou une URL non signée quand un `publicUrl` est configuré, avec une option `download` qui fixe le nom de fichier que le navigateur enregistre.

Un stream a besoin d’un `contentLength`, parce qu’un PutObject unique ne peut pas utiliser l’encodage par morceaux. Définissez `maxSize` et un corps trop gros est refusé avant que quoi que ce soit n’atteigne le réseau.

Tout ce que le package n’enveloppe pas est à une commande de distance via `store.client`, le `S3Client` brut.

## Une drop box sur votre domaine, en un fichier de route.
createTransferHandler() sert le protocole à quatre routes : créer, lire, télécharger, brûler. Il prend une Request et renvoie une Response, donc c’est une route Next, une route Hono, Bun.serve ou un worker, sans adaptateur.

**Next.js App Router**
```ts
// 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**
```ts
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**
```ts
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 })
  },
})
```

Chaque route est publique tant que vous ne passez pas `authorize` : très bien pour une drop box personnelle derrière un proxy, pas pour le reste. Renvoyez `false` pour un simple 401 ou une `Response` pour répondre à votre façon. Avec `raw: 'redirect'`, un téléchargement répond 302 avec une URL présignée, donc les octets ne transitent jamais deux fois par votre serveur.

## De l’état structuré, avec une restauration sûre plutôt qu’optimiste.
putSnapshot() enveloppe votre valeur avec le nom de votre app, la version de schéma, l’appareil et l’expiration, puis la gzippe. getSnapshot() relit l’enveloppe et refuse ce qu’il doit refuser.

```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 quand c’est expiré** — Un snapshot expiré n’est jamais remis, même si l’objet est encore dans le bucket. L’appareil qui reçoit n’a pas à distinguer « n’a jamais existé » de « expiré ».
- **SNAPSHOT_TOO_NEW** — Passez `maxVersion` et un snapshot écrit par une version plus récente lève une erreur au lieu d’atterrir dans une app qui va le mal lire.

## Aucun écrasement silencieux, chez tout fournisseur qui les implémente.
Les deux options sont de simples en-têtes conditionnels S3, et les deux échouent avant que quoi que ce soit ne soit remplacé.

```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` est la façon de réserver un code fraîchement généré sans risquer d’en piétiner un déjà en usage. Le handler réessaie avec un code neuf lors de la rare collision.

`ifMatch` est la façon dont un second appareil apprend qu’il a perdu la course. Il reçoit `PRECONDITION_FAILED`, relit, et fusionne, ce qui est du code applicatif parce que seule votre app sait ce qu’une fusion veut dire.

## Tout lève une S3ndError avec un code stable.
Les échecs détectables localement, un mauvais code, un corps trop gros, des données non sérialisables, sont levés avant que quoi que ce soit n’atteigne le réseau.

```ts
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`: Vide, ou des caractères hors de l’alphabet
- `FILE_TOO_LARGE`: Corps au-dessus du maxSize configuré
- `PRECONDITION_FAILED`: Une écriture ifMatch ou ifAbsent a perdu la course
- `SNAPSHOT_TOO_NEW`: Version de schéma au-dessus du maxVersion donné
- `INVALID_KEY / INVALID_BODY`: Une clé ou un type de corps que le bucket ne peut pas prendre
- `UPLOAD_FAILED / GET_FAILED / …`: S3 a rejeté la requête ; l’erreur d’origine est dans cause

## Chaque option, et la variable d’environnement derrière.
createBucket() sans argument fonctionne dès que S3_BUCKET et les variables AWS habituelles sont définies. Avec un endpoint, la région vaut auto par défaut et l’adressage path-style s’active, ce qu’attendent R2, MinIO et Scaleway.

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

Via votre serveur, un transfert est borné par la limite de requête de votre runtime : 4,5 Mo sur les fonctions Vercel, 6 Mo sur Lambda. Définissez `maxSize` juste en dessous et un upload trop gros coûte une comparaison au lieu d’une requête tronquée.

`createBucket()` ne coûte rien : le client sous-jacent est construit à la première requête, donc l’appeler au niveau du module est très bien.

