Aller au contenu
colis

@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 else

getUrl() 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.

La référence de l’API

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.

Next.js App Router
// 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}`,
})
Hono
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))
Bun.serve
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 expired

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