Agora
Uploads

Direct Sessions

Session-backed browser→S3 multipart uploads — media.direct persists uploadId, part size and confirmed ETags in an UploadSessionStore, so a page reload resumes instead of restarting. Presigned part URLs, collection-aware initiate, and completeDirectUploadToLibrary.

For video-sized files, the deciding cost is bandwidth through your app. The TUS path resumes beautifully, but every chunk crosses the wire twice: client → app, app → S3. The raw direct primitives send bytes straight to S3, but they are stateless — uploadId, the part size and every collected ETag live only in the client's memory, so a page reload orphans the upload and every consuming app ends up growing its own tracking table.

media.direct combines the two halves. Bytes go browser → S3 through presigned part URLs (the app moves no payload at all), while the session — uploadId, the agreed partSize, every confirmed part ETag — is persisted server-side in an UploadSessionStore, the same SPI (and, with the Lucid driver, the same tables) TUS uses. A client that lost its state asks status() and gets back what is already confirmed plus fresh presigned URLs for the rest.

TUS (media.resumable)Direct sessions (media.direct)
Bytes through the appevery chunk (2× bandwidth)none
Disk supportany diskmultipart-capable only (disks.s3())
Resume granularitybyte offsetpart (default 20 MiB)
Client requirementsTUS clientplain PUTs + bucket CORS exposing ETag
SessionsUploadSessionStorethe same UploadSessionStore

Configure

Fully opt-in: add uploads.direct; its presence makes media.direct available and, with routes.enabled, mounts the JSON endpoints. The store is picked exactly like the resumable one — and both can name the same uploadSessions.lucid() tables.

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

export default defineConfig({
  disk: 's3',
  disks: {
    s3: disks.s3({
      bucket: 'media',
      endpoint: 'http://minio.internal:9000',      // server-side operations
      publicEndpoint: 'https://files.example.com', // what the browser's presigned URLs are signed for
      forcePathStyle: true,
    }),
  },
  store: 'lucid',
  stores: { lucid: stores.lucid() },

  collections: [{ name: 'videos', acceptsMimeTypes: ['video/mp4', 'video/webm'] }],

  uploads: {
    direct: {
      store: 'lucid',
      stores: { lucid: uploadSessions.lucid() }, // durable, multi-process sessions
      partSize: 20 * 1024 * 1024,                // default 20 MiB (S3: min 5 MiB, max 10,000 parts)
      presignTtlSeconds: 3600,                   // per-URL lifetime; status() re-issues fresh ones
      sessionTtlSeconds: 24 * 60 * 60,           // omit for never-expiring sessions
      routes: {
        enabled: true,
        prefix: '/media/uploads/direct/sessions',
        collection: 'videos', // reject a wrong declared type at initiate, before any byte moves
        maxSize: 50 * 1024 ** 3,
      },
    },
  },
})
KeyTypeDefaultWhat it controls
storestringin-memorySession store name (a key of stores).
storesRecord<string, UploadSessionStoreFactory>Named session stores (uploadSessions.memory(), uploadSessions.lucid()).
partSizenumberuploads.partSize, else 20 MiBSlice size both sides must agree on.
presignTtlSecondsnumberuploads.presignTtlSeconds, else 3600Lifetime of each presigned part URL.
sessionTtlSecondsnumberneverSession lifetime; an expired session is reaped on access (native upload aborted).
routes.enabledbooleanfalseMount the built-in JSON routes.
routes.prefixstring/media/uploads/direct/sessionsPath prefix for those routes. The browser client's directPath defaults to the same value, so leaving both alone lines them up.
routes.diskstringmedia default diskDisk uploads land on (must be multipart-capable).
routes.maxSizenumberReject initiations whose declared size exceeds this.
routes.collectionstringCollection whose acceptsMimeTypes gates initiate. Omit to accept any type.
routes.middlewarereadonly unknown[][]Middleware applied to the whole route group — the only way to guard these routes.
routes.policy() => Promise<{ default: … }>Lazy thunk loading a DirectUploadPolicy that owns key resolution, completion and error mapping.

routes.middleware is passed straight through to the AdonisJS route group, so it takes exactly what router.group().middleware() takes — [middleware.auth()], a named middleware, your own. It is typed readonly unknown[] only so this config file never has to import AdonisJS. Leave it empty and the routes are open to the internet: the handler itself performs no authorization whatsoever.

routes.policy is a thunk, not a value, so the policy module (and everything it imports) loads only when the routes first serve a request. The provider reads its default export: a policy class is instantiated with no arguments, a ready policy object is used as-is.

config/media.ts
routes: {
  enabled: true,
  middleware: [middleware.auth()],
  policy: () => import('#media/lesson_video_policy'),
}

Bucket CORS must expose ETag

The browser PUTs each part to S3 and must read the ETag response header to confirm it. Configure the bucket's CORS to allow PUT from your origin and expose ETag (ExposeHeaders: ["ETag"] — on MinIO, mc cors set or MINIO_API_CORS_ALLOW_ORIGIN). Behind a private network, also set the disk's publicEndpoint: SigV4 bakes the host into the signature, so URLs must be signed for the endpoint the browser reaches.


The lifecycle

initiate

Validates the part size (S3's 5 MiB floor, 10,000-part cap) and — given a collection — the declared contentType against that collection's acceptsMimeTypes, before the multipart upload even opens. Then persists the session and presigns one URL per part in a single local batch (signing is pure computation, no S3 round-trips).

const created = await media.direct.initiate({
  key: `videos/${lesson.id}/original.mp4`, // resolve server-side, never from the client
  contentType: 'video/mp4',
  size: file.size,
  collection: 'videos',
})
// { id, key, disk, partSize, totalParts, parts: [{ partNumber, url }, …], expiresAt? }

The browser uploads, confirming as it goes

The client slices the file with the returned partSize, PUTs each slice to its URL (in parallel, with retries), reads the ETag response header, and confirms each finished part. The confirmed ETag on the server is the resume currency — everything else is disposable client state.

// per finished part:
await media.direct.confirmPart(created.id, { partNumber, etag })

Reload? status answers with what's left

const s = await media.direct.status(created.id)
// s.completedParts → [{ partNumber, etag }, …]   (already safe on S3)
// s.pendingParts   → [{ partNumber, url }, …]    (FRESH URLs — expiry never strands an upload)
// s.partSize, s.totalParts, s.size               (slice exactly as before)

complete — or straight into the library

complete(id, parts?) merges the caller's parts with the confirmed ones, fails fast naming any missing part numbers (instead of S3's opaque InvalidPart), assembles the object and closes the session.

To land the object in the media library in the same step:

const record = await media.completeDirectUploadToLibrary(created.id, {
  ownerType: 'Lesson',
  ownerId: lesson.id,
  collection: 'videos',
  fileName: 'original.mp4',
  mimeType: 'video/mp4',
}, partsFromClient)

That chains attachExisting — zero-copy adoption of the key, and the collection's whitelist is re-validated against the real bytes (a 16-byte head read). The initiate gate checks only what the client declared; this is the barrier that checks what it sent.

Abort with media.direct.abort(id) — S3 discards the stored parts (and stops charging for them) and the session is dropped. The S3 abort is best-effort: a session whose native upload a bucket lifecycle rule already reaped is still deletable.

media.direct.list(filter?) enumerates in-progress direct sessions, optionally filtered by disk / keyPrefix — the same shape media.resumable.list() returns, since both flows share one session store. It is what an "uploads in progress" screen or a sweeper job reads; a store that can't enumerate yields an empty array rather than an error.

Set an abort lifecycle rule anyway

A client that vanishes mid-upload leaves parts accruing storage charges until someone aborts. Pair session TTLs with a bucket AbortIncompleteMultipartUpload lifecycle rule.


Provider routes

With uploads.direct.routes.enabled, the provider mounts these plain AdonisJS routes (not controllers) under the prefix. Each drives a framework-agnostic DirectUploadHandler over media.direct — mount your own routes over the handler instead if you need custom placement:

MethodPathDoes
POST/Initiate: { fileName, size, contentType?, metadata? }201 + session, part URLs.
GET/:idStatus: confirmed ETags + fresh URLs for pending parts. 404 / 410 when gone / expired.
POST/:id/parts/:partNumberConfirm one part: { etag } → progress.
POST/:id/completeAssemble: { parts? }200, or 409 naming the missing parts.
DELETE/:idAbort.

The client sends a fileName, never a key — the key is always resolved server-side. Which server-side rule applies depends on whether a policy is configured, and the two branches differ completely:

  • without a policy, the handler derives the key from keyFor (default uploads/<token>/<fileName>) and complete returns the raw assembled object without creating any media record;
  • with a policy, keyFor is never called at all. The key comes from the policy's onInitiate decision, complete runs through resolveCompleteattachExistingonComplete, and the response body is whatever onComplete returned.

That second branch is where a real app lives — see Direct upload policy.

Either way, fileName itself is validated before it can reach a storage key — a path separator, an absolute path, ./.., or a control character gets the initiate call rejected with UnsafeFileNameError rather than silently normalized.

And like the TUS and delivery handlers, no authorization is performed by the handler. Guard the routes with routes.middleware, and put per-resource checks in the policy hooks.


Diagnostics

The same agora:media:upload.* events as every other upload path: upload.start (mode direct), upload.progress per confirmed part (offset = bytes safely on S3), upload.complete, upload.abort.


Next steps

  • Direct upload policy — deciding the key, the domain object, and the error mapping
  • Upload modes — the raw stateless primitives these sessions wrap
  • Resumable / TUS — when bytes must flow through the app (any disk, byte-exact resume)
  • S3 diskpublicEndpoint and the hand-rolled SigV4 presigner

On this page