Delivery
A configurable read strategy — public URL, signed URL, or streaming the bytes through your app — plus the framework-agnostic MediaDeliveryHandler you mount behind your own auth.
The library has a configurable strategy for writes (uploads.mode). delivery is its mirror image for reads.
Without it, every app re-derives the same decision by hand: call url() on a public bucket, signedUrl() on a private one — and when the storage isn't reachable from the internet at all (a bucket on a private network, MinIO on an internal host), hand-roll a route that opens a stream and pipes it. That route is the same in every app, so it belongs here.
export default defineConfig({
delivery: {
mode: 'auto', // 'auto' (default) | 'public' | 'signed' | 'proxy'
signedTtlSeconds: 300, // lifetime of the signed URL when mode resolves to 'signed'
},
})| Mode | What you get back | Use when |
|---|---|---|
public | { kind: 'redirect', url } — the disk's stable public URL | the bucket (or CDN in front of it) serves objects to anyone |
signed | { kind: 'redirect', url } — a time-limited signed URL | the bucket is private but reachable from the client |
proxy | { kind: 'stream', stream, mimeType, size, fileName } | the storage is not internet-reachable, or every read must pass your app |
auto | whichever of public / signed fits the disk | you want the sensible default |
How auto decides
It asks the disk: getVisibility(key) — public ⇒ public, private ⇒ signed. Every @adonisjs/drive disk implements it, and the bundled disks.s3() answers from its declarative visibility option (default private, never an ACL round-trip).
A disk that can't answer leaves visibility genuinely unknown, and auto then resolves to signed — the safe read of "unknown", and one that works on a public object too. auto deliberately does not infer visibility from getUrl being callable: a URL can always be synthesised for a private object, so that inference would hand out links that 403.
auto never picks proxy
Proxying is a deployment decision (your app pays the bandwidth), not something to fall into by accident. Set mode: 'proxy' explicitly.
Mounting a route
Or let the library write it
media.access.deliveryRoute() is this whole route, ready-made: it authorizes with your
collections' access rules (or an authorize you pass), supports ?w, ?q, ?conversion,
Accept and signed URLs, and answers errors as HTTP. See Authorization.
The hand-written version below is still how it works underneath.
MediaDeliveryHandler is framework-agnostic, exactly like TusUploadHandler: you mount the route, with your own middleware, and delegate the "how do these bytes reach the client" question to it.
import router from '@adonisjs/core/services/router'
import { MediaDeliveryHandler } from '@adonis-agora/media'
import media from '@adonis-agora/media/services/main'
import { middleware } from '#start/kernel'
const delivery = new MediaDeliveryHandler({ library: media })
router
.get('/media/:id', async ({ params, request, response, auth }) => {
await authorizeMediaAccess(auth.user, params.id) // ← your rule, see below
const result = await delivery.handle({
mediaId: params.id,
conversion: request.input('conversion'),
})
if (result.kind === 'redirect') return response.redirect(result.url)
response.header('content-type', result.mimeType)
if (result.size !== undefined) response.header('content-length', String(result.size))
response.header('content-disposition', `inline; filename="${result.fileName}"`)
return response.stream(result.stream)
})
.use(middleware.auth())The handler performs NO authorization
It resolves a media id to bytes or a URL, and nothing else — the same split TusUploadHandler draws. Who may read a given record is a question only your app can answer, so guard the route (middleware, an ownership check, a signed media token) before calling handle. Mounted unguarded, it publishes every record to anyone who can guess an id.
This is also why the provider mounts no delivery route of its own, unlike the opt-in upload routes: there is no safe default.
The handler's own mode / signedTtlSeconds override the configured ones, so one app can serve avatars as public URLs and exam PDFs through a proxy route.
In proxy mode the stream's mimeType is the storage object's own type, unless that type is generic. An object assembled by a multipart or TUS upload comes back from S3 as application/octet-stream, and the original is then served with the record's type instead (checked against the file's signature on attach). Without that, a PDF served that way opens in no viewer.
Resizing on demand (?w=)
Pass width (and quality) through and the same route serves the image resized, which is the
server half of a next/image-style loader
(<MediaImage loader>):
const result = await delivery.handle({
mediaId: params.id,
conversion: request.input('conversion'),
width: request.input('w'), // '640' — parsed and validated by the library
quality: request.input('q'),
})- Whitelisted. Only the widths and qualities in
delivery.imagesare served. Anything else throwsImageSizeNotAllowedError; map it to a 400. Without the whitelist, one client could fill the storage with a variant per arbitrary width. The defaults arenext/image's and match the React loader's:16 32 48 64 96 128 256 384 640 750 828 1080 1200 1920 2048 3840, quality75. - Resized once. The first request generates the variant and keeps it as the conversion
@w640q75. Every later request is a plain delivery, anddelete()removes it with the record. - Never enlarged. Past the original's width you get the original's size, re-encoded.
- SVGs are served as they are, since they scale by themselves. A non-image throws
NotAnImageError. - Format by
Accept. Pass the request'sAcceptheader (accept: request.header('accept')) and the first ofdelivery.images.formatsthe browser supports is served, likenext/image'sformats. A browser that accepts none gets PNG (for a PNG/GIF original, to keep transparency) or JPEG. The result then carriesvary: 'Accept'. Send it as theVaryheader, or a shared cache hands an AVIF to a browser that asked for WebP. The default is['webp']. AVIF is noticeably smaller but many times slower to encode, so opt in withformats: ['avif', 'webp'].
delivery: {
images: {
widths: [320, 640, 1280], // what your loader asks for
qualities: [75], // the first is the default
formats: ['avif', 'webp'], // negotiated with Accept
},
}The first request for a size pays the resize on the request path. For widths you know up front, eager
presets with
media.library.image() avoid that.
Signed URLs
To keep no media reachable through a public URL, sign delivery URLs, as imgproxy and Thumbor do. The app signs exactly what it hands out, and the route serves only that, until it expires:
delivery: {
mode: 'proxy', // or 'signed': the storage stays private too
path: '/media/:id', // the route below
signing: { secret: env.get('APP_KEY'), expiresIn: 3600 },
}router.get('/media/:id', async ({ params, request, response }) => {
const result = await delivery.handle({
mediaId: params.id,
conversion: request.input('conversion'),
width: request.input('w'),
quality: request.input('q'),
accept: request.header('accept'),
expires: request.input('exp'),
signature: request.input('sig'),
})
// …redirect or stream, as above
})- Building URLs:
media.library.deliveryUrl(id, { conversion?, width?, quality? })builds a signed URL, for example/media/<id>?w=640&exp=…&sig=…. Once signing is configured, every URL frommedia.library.image()goes through the route, signed, and no storage URL leaks. - What the signature covers: the record, the conversion, the size and the expiry. Change any of them and the request is refused (
DeliverySignatureError,invalid; map it to 403). - Any width: a signed request may ask for any width, since the app chose it, so the
delivery.images.widthswhitelist only applies to unsigned requests. - Unsigned requests: with
required(the default) they are refused (missing). Setrequired: falseto let them through with the whitelist. - Routes that authorize by themselves: a route guarded by a session or an ownership check (a download, a doctor's document) sets
new MediaDeliveryHandler({ library: media, requireSignature: false }). It keeps serving its own requests, still honours a signed one, and the rest of the app keeps requiring signatures.requireSignature: truedoes the reverse on a lenient config. - Stable URLs: expiries round up to a multiple of
expiresIn, so a page rendered twice in the same window gets the same URLs and the browser cache keeps working. A URL is valid for between one and two windows. - Authorization: an
<img>cannot send an auth header, but a signed URL carries its authorization. Authorize when you render the page, sign the URLs, and the route needs no session.
For a srcset of signed on-demand sizes, use media.library.image(id, { onDemand: true }). The
React loader can't sign (the secret stays on the server), so private images use the
server-built descriptor.
Without a route
library.deliver() returns the same union, for jobs, console commands, or a controller you'd rather write yourself:
const result = await media.library.deliver(id, { conversion: 'thumb', mode: 'signed' })| Option | Type | What it does |
|---|---|---|
conversion | string | Deliver a named conversion instead of the original (generated lazily if absent). |
mode | DeliveryMode | Override the configured mode for this call. |
signedTtlSeconds | number | Override the signed-URL lifetime for this call. |
In proxy mode the mimeType and size come from the disk's metadata rather than the record's, because a conversion has its own format and length — a thumb of a PNG may well be a WEBP.
HLS packages
A transformer-generated HLS or DASH conversion is not one file but a package of manifests and segments, so deliver(id, { conversion: 'hls' }) would only hand you the entry manifest. Serve packages through the stream route, media.access.streamRoute(). It serves every file of the package under one URL directory and authorizes each request the way this page's route does.
S3 Disk
The bundled disks.s3() driver — an S3 (or S3-compatible) disk with extended operations (copy/move/deleteMany/list/size/stat), native multipart, and presigned URLs. The AWS SDK is an optional, lazily-imported peer.
Authorization
media.access — who may read and upload media: a ready delivery route, per-collection rules, authorized image(), and bridges to @adonis-agora/authkit and @adonis-agora/authz.