Aller au contenu
s3nd.sh

s3nd

Des fichiers et des snapshots, dans votre bucket.

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.

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

01L’API de fichiers

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.

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.

La référence de l’API

02Le handler

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

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.

03Snapshots

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.

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.

04Écritures conditionnelles

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

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

Deux appareils, un snapshot

05Erreurs

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.

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

06Configuration

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.

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.

Déposez un fichier. Donnez le code.

Pointez-le sur le bucket que vous payez déjà. Rien à déployer, aucune inscription, personne au milieu.