@colis/core
Votre propre circuit de livraison, en Node.
Le paquet sur lequel repose la page de livraison : une API fichiers sur votre bucket, les routes de transfert en un seul fichier, et de petits enregistrements à côté de chaque fichier. Le seul paquet qui détient des identifiants, donc le seul qui tourne sur votre serveur.
import { createBucket, createTransferHandler } from '@colis/core'
const store = createBucket({ bucket: 'livraisons' })
// The transfer routes on your own domain, in one route file.
export const { GET, POST, DELETE } = createTransferHandler({
bucket: store,
expiresIn: 7 * 24 * 3600,
authorize: (request) => request.headers.get('authorization') === `Bearer ${process.env.TOKEN}`,
})Pas encore sur npm : les paquets se construisent depuis le dépôt en attendant leur publication.
01L’API fichiers
Cinq verbes sur votre bucket.
Chaînes, buffers, Blobs et flux sont acceptés. Les clés font l’aller-retour : ce que renvoie upload() se redonne tel quel à get(), getUrl() et delete().
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
await store.put(code, file, { ifAbsent: true }) // claim a code, never overwrite one
store.client // the plain S3Client, for anything elsegetUrl() renvoie une URL présignée par défaut, ou une URL non signée quand une publicUrl est configurée, avec une option download qui fixe le nom sous lequel le navigateur enregistre.
ifAbsent n’écrit que si rien n’existe encore sous la clé : c’est ainsi qu’un nouveau code est réservé sans piétiner un code en service. Un flux demande une contentLength, et maxSize refuse un corps trop gros avant que quoi que ce soit ne parte sur le réseau.
Tout ce que le paquet n’enveloppe pas est à une commande près via store.client, le S3Client brut.
02Le handler
Les routes de transfert, en un fichier de route.
createTransferHandler() sert le protocole à quatre routes : créer, lire, télécharger, supprimer. Une Request en entrée, une Response en sortie : route Next, route Hono, Bun.serve ou worker, sans adaptateur.
// app/api/transfers/[[...route]]/route.ts
import { createBucket, createTransferHandler } from '@colis/core'
export const { GET, POST, DELETE } = createTransferHandler({
bucket: createBucket({ bucket: 'livraisons' }),
expiresIn: 7 * 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 '@colis/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))import { createBucket, createTransferHandler } from '@colis/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 })
},
})Toutes les routes sont publiques tant que vous ne passez pas authorize. Renvoyez false pour un simple 401, ou votre propre Response. Avec raw: 'redirect', un téléchargement répond 302 vers une URL présignée, et les octets ne transitent pas deux fois par votre serveur. La page de livraison place devant ce handler son mot de passe, son accusé de réception et ses webhooks.
03Enregistrements
Un petit JSON à côté du fichier, avec une expiration.
putSnapshot() range une valeur dans une enveloppe auto-descriptive, compressée, avec le nom de votre application, une version de schéma et une expiration. La page de livraison tient ainsi ses accusés de réception.
// How the delivery page keeps a client's answer beside the transfer.
await store.putSnapshot(`meta/${code}.verdict`, { decision: 'approved', at }, {
app: 'drop',
expiresIn: 7 * 24 * 3600,
ifAbsent: true, // one answer, never overwritten
})
const verdict = await store.getSnapshot(`meta/${code}.verdict`)
verdict?.data // → the record, or null when unknown or expirednull une fois expiré
Un enregistrement expiré n’est jamais renvoyé, même si l’objet est encore dans le bucket.
La première écriture gagne
Avec ifAbsent, une seconde écriture sous la même clé échoue en PRECONDITION_FAILED au lieu de remplacer la première : c’est ce qui rend la réponse d’un client définitive.
04Erreurs
Tout lève une ColisError avec un code stable.
Les échecs détectables localement, un code invalide, un corps trop gros, des données non sérialisables, sont levés avant que quoi que ce soit ne parte sur le réseau.
import { isColisError } from '@colis/core'
try {
await store.upload(body, { filename })
} catch (error) {
if (isColisError(error) && error.code === 'FILE_TOO_LARGE') {
return Response.json({ error: 'Too large to send in one piece' }, { status: 413 })
}
throw error
}ColisError · code
- INVALID_SYNC_CODE
- Vide, ou des caractères hors de l’alphabet
- FILE_TOO_LARGE
- Corps au-delà du maxSize configuré
- PRECONDITION_FAILED
- Une écriture ifMatch ou ifAbsent a perdu la course
- SNAPSHOT_TOO_NEW
- Version de schéma au-delà du maxVersion donné
- INVALID_KEY / INVALID_BODY
- Une clé ou un type de corps que le bucket n’accepte pas
- UPLOAD_FAILED / GET_FAILED / …
- S3 a refusé la requête ; l’erreur d’origine est dans cause
05Configuration
Chaque option, et la variable d’environnement derrière.
createBucket() sans argument fonctionne dès que COLIS_BUCKET et les variables AWS habituelles sont définies. Avec un endpoint, la région passe à auto et l’adressage par chemin s’active, ce qu’attendent R2, MinIO et Scaleway.
createBucket({
bucket: 'livraisons', // or COLIS_BUCKET / S3_BUCKET
region: 'eu-west-3', // or COLIS_REGION / AWS_REGION
credentials: { … }, // omit for the AWS provider chain
endpoint: 'https://…', // R2, MinIO, Scaleway, Wasabi — or COLIS_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 envoi est limité par la taille de requête de votre runtime : 4,5 Mo sur les fonctions Vercel. Réglez maxSize juste en dessous, et un envoi trop gros coûte une comparaison au lieu d’une requête tronquée ; la page de livraison envoie tout ce qui dépasse directement au bucket.
createBucket() ne coûte rien : le client sous-jacent se construit à la première requête, l’appeler au niveau du module ne pose aucun problème.
Vous envoyez. Ils valident. Vous le savez.
Votre stockage, une page de livraison à votre nom, un webhook à chaque étape. Rien d’hébergé chez un tiers.