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 elsegetUrl() 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.
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.
// 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}`,
})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))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?.devicenull 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.
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.