Agora
Uploads

Upload Modes

Two upload strategies over a multipart-capable disk — proxy (bytes stream through your app) and direct (the browser uploads straight to S3 via presigned multipart part URLs). MediaManager.uploads and the opt-in provider routes under /media/uploads.

Beyond the buffer-once attach / createFromFile calls, the library ships an upload coordinator for large files, with two strategies:

  • proxy — the client sends bytes to your app, which streams them to the disk (putStream, no in-memory buffering). Works on any disk.
  • direct — for a multipart-capable disk (the bundled S3 disk), your app hands the client presigned multipart part URLs; the browser PUTs each part straight to S3, then your app assembles them. Bytes never pass through your server.

auto (the default) picks direct on a multipart-capable disk, else proxy.

These are the raw, stateless primitives

uploadId and the collected part ETags live only in the caller's hands — a page reload loses them. For browser uploads that must survive a reload, use the session-backed direct sessions flow (media.direct), which persists the session server-side and wraps exactly these primitives.

This is the server side. For a browser client that speaks this contract (progress, retries, concurrency), use @adonis-agora/media-react — its createMediaUploadClient / useMediaUpload implement exactly the routes below.


Configure

Upload behaviour lives under the uploads key. HTTP routes are opt-in — omit routes to drive uploads only through the MediaManager methods and wire your own endpoints.

config/media.ts
import { defineConfig, disks, stores } from '@adonis-agora/media'

export default defineConfig({
  disk: 's3',
  disks: { s3: disks.s3({ bucket: 'my-bucket', region: 'us-east-1' }) },
  store: 'lucid',
  stores: { lucid: stores.lucid() },

  uploads: {
    mode: 'auto',            // 'auto' (default) | 'proxy' | 'direct'
    partSize: 8 * 1024 * 1024, // multipart part size, default 8 MiB (S3 min is 5 MiB)
    presignTtlSeconds: 3600,   // lifetime of presigned part URLs, default 3600
    routes: { enabled: true, prefix: '/media/uploads' }, // opt-in HTTP routes
  },
})
KeyTypeDefaultWhat it controls
mode'auto' | 'proxy' | 'direct'autoDefault strategy. direct on a non-multipart disk throws UploadNotSupportedError.
partSizenumber8 MiBMultipart part size for direct uploads.
presignTtlSecondsnumber3600Lifetime of each presigned part URL.
routes.enabledbooleanfalseMount the built-in upload routes.
routes.prefixstring/media/uploadsPath prefix for those routes.

media.uploads

The manager exposes an UploadManager at media.uploads, plus convenience methods on media itself. The object key is yours to resolve server-side — never trust a client-supplied key in a real app.

import media from '@adonis-agora/media/services/main'

// 1. Begin a multipart upload — get one presigned URL per part.
const { uploadId, key, disk, partSize, parts } = await media.initiateDirectUpload({
  key: `users/${user.id}/video.mp4`,
  contentType: 'video/mp4',
  size: file.size, // used to pre-compute how many part URLs to return
})
// parts: [{ partNumber: 1, url }, { partNumber: 2, url }, …]

// 2. The browser PUTs each part straight to S3 and collects the ETag response header.
//    (Do this in the client — see @adonis-agora/media-react.)

// 3. Assemble the parts into the final object.
await media.completeDirectUpload({ key, uploadId, parts: [{ partNumber: 1, etag }, …] })

// …or discard an in-flight upload:
await media.abortDirectUpload({ key, uploadId })

// Re-presign a single (e.g. retried) part:
await media.uploads.presignPart({ key, uploadId, partNumber: 2 })

media.uploads.resolveMode({ disk, mode }) tells you which strategy a disk will use ('proxy' or 'direct'), honouring the per-call override, the configured default, and the disk's multipart capability.

Resolving the mode yourself

The decision is one pure function, exported as resolveUploadMode — useful when you drive an upload outside the manager, or want to tell a client which strategy to expect before it commits to one:

import { resolveUploadMode, isMultipartCapable } from '@adonis-agora/media'
import type { ResolvedUploadMode, UploadModeLevels } from '@adonis-agora/media'

const disk = media.disk('s3')

const mode: ResolvedUploadMode = resolveUploadMode(
  { global: 'auto', perCall: request.input('mode') } satisfies UploadModeLevels,
  isMultipartCapable(disk),
  's3',
)

UploadModeLevels names the three places a mode can be set, most specific first: perCallperDiskglobal, defaulting to 'auto' when none is given. (perDisk is reserved — the bundled disks don't use it.) The second argument is the disk's capability, and the third is the disk name, used only to build a readable error.

The rules are exactly the ones the manager applies: proxy is always allowed (every disk accepts bytes), direct throws UploadNotSupportedError on a disk without native multipart rather than silently downgrading, and auto picks direct when it can and proxy when it can't. That difference is the point of having both direct and auto — one is a requirement, the other is a preference.

These upload methods write bytes to a disk key — they do not create a MediaLibrary record. Once the object exists, register it (e.g. media.library.attach pointing at the key, or your own row) if you want it in a collection.


Provider routes

With uploads.routes.enabled, the provider mounts these plain AdonisJS routes (not controllers) under uploads.routes.prefix (default /media/uploads). Each resolves the MediaManager singleton and delegates to it:

MethodPathDoes
POST/direct/initiateBegin a multipart upload; returns presigned part URLs.
POST/direct/:uploadId/parts/:partNumberPresign one (retried) part.
POST/direct/:uploadId/completeAssemble the uploaded parts.
DELETE/direct/:uploadIdAbort the upload.
PUT/proxyStream bytes through the app to the disk.

The key (and optional disk) come from the JSON body or query string. The routes are unguarded by design — add your own auth middleware and resolve the key per user/tenant server-side so a client can't point an upload at another object.

Forcing mode: 'direct' on a disk that can't do native multipart throws UploadNotSupportedError (E_MEDIA_UPLOAD_NOT_SUPPORTED), which the routes map to 400.


Diagnostics

Direct and proxy uploads emit agora:media:upload.start / upload.complete / upload.abort events on the diagnostics bus (see Configuration → Diagnostics), captured by Telescope when present.


Next steps

  • Direct sessions — these primitives plus a persisted session: resume after a page reload, key resolved server-side
  • Resumable / TUS — chunked uploads that resume after a dropped connection
  • React client — a browser client that drives these routes
  • S3 disk — the multipart-capable disk direct mode needs

On this page