@colis/core
Your own delivery flow, in Node.
The package the delivery page is built on: a file API over your bucket, the transfer routes as one route file, and small records beside each file. The only package that holds credentials, so the only one that runs on your server.
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}`,
})Not on npm yet: the packages are built from the repository until they are published.
01The file API
Five verbs over your bucket.
Strings, buffers, Blobs and streams are all accepted. Keys round-trip: what upload() returns is what you hand back to get(), getUrl() and 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() returns a presigned URL by default, or an unsigned one when a publicUrl is configured, with a download option that sets the filename the browser saves.
ifAbsent writes only if nothing is stored under the key yet: that is how a fresh code is claimed without trampling one in use. A stream needs a contentLength, and maxSize refuses an oversized body before anything reaches the network.
Anything the package does not wrap is one command away through store.client, the plain S3Client.
02The handler
The transfer routes, in one route file.
createTransferHandler() serves the four-route protocol: create, read, download, burn. Request in, Response out: a Next route, a Hono route, Bun.serve or a worker, without an adapter.
// 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 })
},
})Every route is public unless you pass authorize. Return false for a plain 401, or a Response of your own. With raw: 'redirect' a download answers 302 with a presigned URL, so the bytes do not transit your server twice. The delivery page puts its password check, its receipt and its webhooks in front of this handler.
03Records
Small JSON beside the file, with an expiry.
putSnapshot() stores a value in a self-describing envelope, gzipped, with your app name, a schema version and an expiry. The delivery page keeps its receipts this way.
// 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 when expired
An expired record is never handed over, even if the object is still in the bucket.
The first write wins
With ifAbsent, a second write under the same key fails with PRECONDITION_FAILED instead of replacing the first: that is what makes a client’s answer final.
04Errors
Everything throws a ColisError with a stable code.
Failures that can be caught locally, a bad code, an oversized body, unserializable data, are raised before anything reaches the network.
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
- Empty, or characters outside the alphabet
- FILE_TOO_LARGE
- Body above the configured maxSize
- PRECONDITION_FAILED
- An ifMatch or ifAbsent write lost the race
- SNAPSHOT_TOO_NEW
- Schema version above the maxVersion given
- INVALID_KEY / INVALID_BODY
- A key or a body type the bucket cannot take
- UPLOAD_FAILED / GET_FAILED / …
- S3 rejected the request; the original error is in cause
05Configuration
Every option, and the environment variable behind it.
createBucket() with no arguments works once COLIS_BUCKET and the usual AWS variables are set. With an endpoint, the region defaults to auto and path-style addressing turns on, which is what R2, MinIO and Scaleway expect.
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
})Through your server, an upload is bound by your runtime’s request limit: 4.5 MB on Vercel functions. Set maxSize just under it and an oversized upload costs a comparison instead of a truncated request; the delivery page sends anything larger straight to the bucket.
createBucket() is cheap: the underlying client is built on the first request, so calling it at module scope is fine.
Send it. They sign off. You know.
Your storage, a delivery page under your name, a webhook at every step. Nothing hosted by someone else.