Agora
Uploads

Resumable / TUS

Resumable, chunked uploads over the tus 1.0.0 protocol — media.resumable and the opt-in TUS routes under /media/uploads/tus, backed by a pluggable UploadSessionStore (in-memory + Lucid) that persists offset/length/metadata/expiry so a dropped connection resumes.

Large uploads shouldn't restart from zero when a connection drops. The package implements the tus 1.0.0 resumable protocol: bytes flow through the backend in chunks, each chunk is written immediately as a part on the target disk (native S3 multipart when available, else buffered part objects), and a session persists the offset so a HEAD tells the client exactly where to resume.

The engine (ResumableUploadManager) and the TUS server (TusUploadHandler) are framework-agnostic; the provider mounts thin AdonisJS routes over them. A browser client that speaks this protocol ships in @adonis-agora/media-react (mode: 'tus', the default there).


Configure

Resumable uploads are fully opt-in. Add uploads.resumable; its presence makes media.resumable available and, with routes.enabled, mounts the TUS endpoints. The session store is picked by name from a map built with the uploadSessions factory (each peer imported lazily, mirroring stores).

config/media.ts
import { defineConfig, disks, stores, uploadSessions } 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: {
    resumable: {
      store: 'lucid',
      stores: {
        memory: uploadSessions.memory(),
        lucid: uploadSessions.lucid(), // durable, multi-process sessions
      },
      sessionTtlSeconds: 24 * 60 * 60, // TUS expiration; omit for never-expiring sessions
      routes: {
        enabled: true,
        prefix: '/media/uploads/tus',
        maxSize: 5 * 1024 ** 3,
        collection: 'exams', // enforce this collection's acceptsMimeTypes up front
      },
    },
  },
})
KeyTypeDefaultWhat it controls
storestringin-memorySession store name (a key of stores).
storesRecord<string, UploadSessionStoreFactory>Named session stores (uploadSessions.memory(), uploadSessions.lucid()).
tmpPrefixstring.uploadsPrefix for temporary chunk parts on the target disk (buffered path).
sessionTtlSecondsnumberneverSession lifetime (TUS expiration).
routes.enabledbooleanfalseMount the TUS protocol routes.
routes.prefixstring/media/uploads/tusPath prefix for the TUS routes.
routes.diskstringmedia default diskDisk resumable uploads land on.
routes.maxSizenumberReject creations whose Upload-Length exceeds this.
routes.collectionstringCollection whose acceptsMimeTypes gates the upload (see below). Omit to accept any type.

Rejecting the wrong file type early

acceptsMimeTypes belongs to a collection, and a collection is only known at attach time — so without help, TUS accepts any binary and the user discovers they sent the wrong file after uploading all 20 MB of it. In a protocol that exists for large files, that is the worst possible moment to say no.

Set routes.collection and the handler enforces that collection's whitelist at the two earliest points the protocol allows:

PointWhat is checkedOn failure
POST (create)the filetype the client declared in Upload-Metadata415 Unsupported Media Typebefore a single byte is uploaded; no session is created
first PATCHthe real magic-byte signature of the leading bytes, via the same detector attach uses415 — the session and any partial object are discarded, so a client that lied in filetype pays for one chunk, not the whole file

Later chunks are not sniffed: a signature lives at the head of a file or nowhere.

The collection config stays the single source of truth — you name the collection, never the MIME list, so the TUS gate and the attach-time check cannot drift apart.

This is bandwidth economy, not the security boundary

The TUS checks see only the first chunk and are skipped entirely when no collection is configured. The storage invariant is still guaranteed by attach / attachExisting, which re-validate the assembled object — that remains the final barrier. Keep attaching through the library; the TUS gate is there to fail fast, not to replace it.

Building the handler yourself (outside the provider's routes) works the same way — pass the registry, not a list:

new TusUploadHandler({
  manager: media.resumable,
  disk: media.storage.defaultDisk,
  basePath: '/media/uploads/tus',
  collection: 'exams',
  collections: media.collections,
})

Naming a store with no matching factory throws UploadSessionStoreNotConfiguredError — a silent non-durable fallback would lose in-flight uploads. Only the zero-config path (no store named) resolves to the in-memory store, which is single-process and non-durable: fine for a single node, not for a cluster. For durable sessions select lucid and run node ace migration:run.


The Lucid session store

configure publishes a migration for the TUS lucid store alongside the media migration. It creates two portable tables — media_upload_sessions (offset / length / metadata / expiry) and media_upload_parts (the per-part ETag side-index for assembling native S3 multipart) — with JSON as TEXT and timestamps as epoch-ms integers, so it runs on SQLite / Postgres / MySQL. Import the class from @adonis-agora/media/upload_sessions/lucid if you need it by hand.

uploadSessions.lucid({ connection?, table?, partsTable? })connection defaults to Lucid's default; table defaults to media_upload_sessions; partsTable defaults to media_upload_parts.


TUS protocol routes

With routes.enabled, these routes are mounted under routes.prefix (default /media/uploads/tus). The server implements the creation, termination and expiration extensions.

MethodPathDoes
OPTIONS/Advertise Tus-Version / Tus-Extension / Tus-Max-Size.
POST/Create a session from Upload-Length + Upload-Metadata; returns 201 + Location.
HEAD/:idReport Upload-Offset (the resume point), plus Upload-Length / Upload-Expires.
PATCH/:idAppend bytes at Upload-Offset (Content-Type: application/offset+octet-stream); auto-completes at the declared length.
DELETE/:idTerminate (abort) an in-flight upload.

Storage errors map onto TUS status codes: offset conflict → 409, expired session → 410 Gone, unknown session → 404. The final object key defaults to uploads/<token>/<filename> (from the filename metadata field). That filename is validated as a single, safe path segment before it is used — a traversal attempt (../../etc/passwd) or an absolute path throws UnsafeFileNameError rather than being written somewhere unexpected.

A standard tus client (e.g. tus-js-client) points straight at routes.prefix and works unchanged. @adonis-agora/media-react includes a matching client so you don't need a separate dependency.


media.resumable

The same engine is available programmatically. It throws ResumableUploadsNotConfiguredError unless uploads.resumable is configured; guard with media.hasResumable.

Create a session (reserves a native S3 multipart upload when the disk supports it):

const session = await media.resumable.createUpload({
  disk: 's3',
  key: `users/${user.id}/video.mp4`,
  size: file.size,       // TUS Upload-Length (optional)
  contentType: 'video/mp4',
})

Append chunks — offset must equal the session's current offset, and each call returns the new offset:

let { offset } = await media.resumable.writeChunk(session.id, 0, chunk1)
;({ offset } = await media.resumable.writeChunk(session.id, offset, chunk2))

await media.resumable.status(session.id) // { offset, size, expiresAt } — drives HEAD

Assemble into the final object (or abort to discard):

const { key, disk, size } = await media.resumable.complete(session.id)
// await media.resumable.abort(session.id)

Turning a finished upload into a media record

complete() hands back a raw object on a disk. To make it a library asset, do not read the bytes back into library.attach() — that buffers the whole file and rewrites the object, exactly what a resumable upload exists to avoid. Register the object where it already is:

const record = await media.completeUploadToLibrary(session.id, {
  ownerType: 'Patient',
  ownerId: patient.id,
  collection: 'exams',
  fileName: 'exam.pdf',
  mimeType: 'application/pdf',
})

That is resumable.complete() followed by library.attachExisting({ ...input, key, disk, size }). It is opt-in: resumable.complete() stays the raw primitive for blobs that should not become library assets.

library.attachExisting() is the underlying primitive and works for any object already on a disk (a direct-S3 upload, a file written by a job, an import):

await media.library.attachExisting({
  ownerType: 'Patient',
  ownerId: patient.id,
  collection: 'exams',
  key: 'uploads/exam-1.pdf',   // already stored — never read, never rewritten
  disk: 's3',                  // else the collection's disk, else the default
  fileName: 'exam.pdf',
  mimeType: 'application/pdf',
  size: 1_048_576,             // optional — read from the object's metadata when omitted
})

Everything after storage matches attach(): acceptsMimeTypes, single: true atomic replace, ordering, eager conversions and agora:media:attach diagnostics. Missing key → MediaObjectMissingError. Pass moveIntoLayout: true to relocate the object into the library's ownerType/ownerId/collection/id/fileName layout — this uses the disk's native server-side move (disks.s3(); see S3 disk) and throws on a disk without one, rather than streaming the bytes through the app to fake it. Registering in place is the default.


media.resumable.list(filter?) returns in-progress sessions (optionally filtered by disk / keyPrefix) for an "uploads in progress" view — the Dashboard uses it. It returns an empty array, not an error, when the configured store can't enumerate.

media.resumable.listParts(id) goes one level deeper: every part recorded for one session, as { partNumber, etag }. On a multipart-capable disk that is the real S3 part manifest — what an operator needs to see why a stalled upload is stalled, and what the console's upload drill-in renders. A buffered session (a disk with no native multipart) has no parts to record and comes back empty.

Sessions emit agora:media:upload.* diagnostics events, captured by Telescope.


Custom session stores

An UploadSessionStore is a POJO implementing create / get / update / delete, plus optional addPart / listParts (only needed for multipart-capable disks) and an optional list (for the admin view). Wire it via an UploadSessionStoreFactory thunk in uploads.resumable.stores so any peer it needs is imported lazily.


Next steps

  • Upload modes — single-shot proxy and direct-S3 uploads
  • Direct sessions — browser→S3 multipart with a persisted, resumable session (no bytes through the app)
  • React clientuseMediaUpload({ mode: 'tus' })
  • S3 disk — native multipart for resumable uploads

On this page