React Client
@adonis-agora/media-react — a React hook (useMediaUpload), a headless-friendly MediaUploader component, and a framework-free browser upload client (createMediaUploadClient) that speak the provider's actual upload contract across TUS, direct-S3 multipart, and proxy strategies.
@adonis-agora/media-react is the first-party browser companion to the server upload routes. It speaks the AdonisJS provider's actual HTTP contract across all three strategies:
- TUS resumable (default) —
POST/HEAD/PATCH/DELETEagainsttusPath(default/media/uploads/tus, matchinguploads.resumable.routes.prefix), resuming from the server'sUpload-Offset. - Direct-S3 multipart — the direct-session contract against
directPath(default/media/uploads/direct/sessions, matchinguploads.direct.routes.prefix):POST {directPath}initiates and returns presigned part URLs, each partPUTs straight to S3,POST {directPath}/:id/parts/:partNumberconfirms itsETag, andPOST {directPath}/:id/completeassembles the object.GET {directPath}/:idreports a session's state andDELETE {directPath}/:idtears it down. - Proxy — a single
PUT {uploadsPath}/proxystreams the whole body through the app.
Two paths, because there are two server prefixes
uploadsPath and directPath are separate options because they address separate mounts on the
server: the core upload routes (uploads.routes.prefix, default /media/uploads, where the proxy
endpoint lives) and the direct-session routes (uploads.direct.routes.prefix, default
/media/uploads/direct/sessions). Each client option defaults to the matching server default, so
a client talking to a stock server sets neither.
Set directPath when you moved the server's direct-session prefix:
createMediaUploadClient({ directPath: '/api/v1/upload-video' })An explicit uploadsPath also supplies directPath when you don't set one, which is how clients
were configured before directPath existed — so a single-path setup keeps working untouched.
The older stateless routes under uploads.routes.prefix (POST /direct/initiate, POST /direct/:uploadId/complete) exist too, but this client does not speak them. They are the raw primitives documented in Upload modes; the client always uses the session-backed contract, because that is the one that survives a page reload.
Separate package
Install it alongside the server library: npm i @adonis-agora/media-react react. react is an optional peer — the ./client entry point is framework-free and needs no React. This package matches the routes documented in Upload modes and Resumable / TUS.
useMediaUpload
A hook with progress / status state and pause / resume / abort. For tus, resume continues from the server offset; for direct / proxy a resume restarts the transfer.
import { useMediaUpload } from '@adonis-agora/media-react'
function Uploader() {
const { upload, pause, resume, abort, status, progress } = useMediaUpload({ mode: 'tus' })
return (
<div>
<input
type="file"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) upload(file, { filename: file.name, contentType: file.type })
}}
/>
<progress value={progress} max={1} />
<span>{status}</span>
</div>
)
}mode:'tus'(resumable, default) ·'direct'(presigned S3 multipart) ·'proxy'.- State:
status('idle' | 'uploading' | 'paused' | 'success' | 'error'),progress(0..1),result,location(TUS session),error,resumable(see below). - Actions:
upload(file, meta),pause(),resume(),abort()(terminates the session server-side),reset(). - Only
proxytakes akeyin the uploadmeta. TUS and direct both derive the key server-side, andmeta.keyis ignored on those paths — a client-chosen key would be a hole you'd have to close anyway.
The hook accepts every MediaUploadClientOptions field (below) plus an optional pre-built client for tests or a shared instance, and a storageKey for cross-reload resume.
The typed completion body
useMediaUpload<TComplete>() types what the server's complete returned. For a direct upload with a DirectUploadPolicy, that body is whatever the policy's onComplete handed back — a media id, a status, a whole domain object:
type Completion = { mediaId: string; status: 'processing' }
const { upload } = useMediaUpload<Completion>({ mode: 'direct' })
const result = await upload(file, { filename: file.name, contentType: file.type })
if (result.mode === 'direct') {
router.visit(`/videos/${result.body.mediaId}`) // typed
}The result is a discriminated union — { mode: 'tus', location }, { mode: 'direct', uploadId, key, disk, body }, { mode: 'proxy', key, disk } — so narrowing on mode is what gets you to body at all.
TComplete is an assertion, not a validation. That body is raw server JSON and nothing at this
boundary checks it. Parse it with a schema before trusting its shape.
Pausing and resuming
pause() aborts the in-flight transfer and settles into status: 'paused'; resume() picks the same file back up. What "resume" means depends on the strategy:
tus— a true byte-offset resume. The client re-HEADs the session, learns the server's offset, and continues from exactly there.direct— a real resume at part granularity.pauseaborts the in-flight partPUTs;resumecallsdirectSessionStatusand continues from the session's still-pending parts with fresh presigned URLs. Parts already confirmed are never re-uploaded.proxy— a single-shot transfer, so a resume restarts it.
abort() differs from reset() in one important way: abort tears the session down server-side (a TUS DELETE, a direct-session DELETE) as well as clearing local state, while reset only clears locally. Reset an upload you might resume; abort one you are done with.
Resuming a direct upload across a reload
Pause/resume survives a component unmount but not a page reload — the session coordinates live in memory. Pass storageKey and they are persisted to localStorage on initiate:
const { upload, resume, resumable, status, progress } = useMediaUpload({
mode: 'direct',
storageKey: `upload:lesson:${lessonId}`,
})
return resumable ? (
<button type="button" onClick={() => resume()}>
Resume “{resumable.fileName}”
</button>
) : (
<input type="file" onChange={(e) => { /* upload(file, meta) */ }} />
)How it behaves, because the details are what make it trustworthy:
- On mount, the hook probes the persisted session. If the server still has pending parts,
resumableis populated with{ uploadId, fileName }— your cue to offer "resume" instead of "choose a file". If the session is finished or gone, the entry is dropped silently. - On
upload()of the same file, a persisted session is reused when the file name and the byte size both match. Requiring the size too is what stops a revised same-named file from splicing its bytes into the old session. - The server is the authority. On resume the client re-reads the coordinates from
directSessionStatus— key, disk, part size, pending parts — rather than trusting the stored copy. - A definitive "gone" (
404/410) drops the entry and falls through to a fresh initiate, so an expired session never bricks thatstorageKey. A transient failure (network blip,5xx) keeps the entry resumable and surfaces the error instead of silently discarding progress and restarting at 0%. Telling those two apart is exactly whatMediaHttpError.statusis for. - The entry is cleared on success, on
abort()and onreset().
Because resume() needs the original Blob back, offer it from a control that still has the file (or re-prompt for it) — the hook persists the session's coordinates, never the bytes. storageKey is ignored by tus and proxy, and should be unique per upload slot: two uploaders sharing one key would fight over the same entry.
MediaUploader
A minimal, headless-friendly component wired to the hook. The default markup is a file input + progress bar; pass render to take full control.
import { MediaUploader } from '@adonis-agora/media-react'
<MediaUploader mode="tus" accept="image/*" onUploaded={(r) => console.log(r)} />
// Fully headless — bring your own UI:
<MediaUploader
mode="direct"
render={({ status, progress, selectFile }) => (
/* your markup; call selectFile(file) to start */
)}
/>| Prop | Type | What |
|---|---|---|
mode | 'tus' | 'direct' | 'proxy' | Strategy (default tus). |
accept | string | accept attribute for the file input. |
meta | Partial<UploadMeta> | Extra metadata merged into every upload (key / disk for proxy, metadata / tusPath for TUS). |
unstyled | boolean | Skip the default stylesheet. |
className | string | Class on the wrapper element. |
onUploaded / onError | callbacks | Fire on success / failure. |
render | function | Headless override receiving the hook state + selectFile(file). |
The default styling uses Agora design tokens (--agora-primary, --agora-primary-soft, --agora-ink) through overridable --agora-media-* CSS custom properties — retheme by setting a variable, or pass unstyled to drop the stylesheet. No vendor branding. (ensureMediaUploaderStyles / mediaUploaderCss / MEDIA_UPLOADER_STYLE_ID are exported if you manage the stylesheet yourself.)
createMediaUploadClient (framework-free)
The ./client entry point needs no React and drives the same three strategies directly.
import { createMediaUploadClient } from '@adonis-agora/media-react/client'
const client = createMediaUploadClient({
baseUrl: 'https://api.example.com',
headers: { Authorization: `Bearer ${token}` }, // merged into every app request
getHeaders: async () => ({ Authorization: `Bearer ${await fresh()}` }), // short-lived tokens
})
await client.uploadTus(file, { filename: 'a.png', contentType: 'image/png' })
await client.uploadDirect(file, { filename: 'a.png', contentType: 'image/png' }) // key: server-side
await client.uploadProxy(file, { filename: 'a.png', key: 'u/1/a.png' }) // key: requiredThose are the three upload entry points; the client also exposes the session primitives underneath them (createTusSession, tusOffset, abortTus, directSessionStatus, abortDirectSession) and an injectable part transport. See Upload client for the full surface.
| Option | Default | What |
|---|---|---|
baseUrl | '' (same-origin) | Origin (and optional path) prepended to every relative endpoint. |
tusPath | /media/uploads/tus | TUS base path — match uploads.resumable.routes.prefix. |
uploadsPath | /media/uploads | Core upload routes — where the proxy endpoint lives. Match uploads.routes.prefix. |
directPath | /media/uploads/direct/sessions | Direct-session routes. Match uploads.direct.routes.prefix. Falls back to uploadsPath when that is set and this isn't. |
chunkSize | 8 MiB | Bytes per TUS chunk. The direct-S3 part size is decided by the server, not here. |
concurrency | — | Reserved. Direct-S3 uploads are sequential today, so this does not apply to them; it is kept for forward compatibility. |
retries | 3 | Per-chunk / per-part retry attempts. |
fetchImpl | global fetch | Custom fetch for tests / non-browser runtimes. |
partUploader | xhrPartUploader | Transport for one direct-S3 part PUT. See Upload client. |
headers | — | Static headers merged into every app request. |
getHeaders | — | Resolved fresh before every app request (short-lived tokens); wins on key conflict. |
UploadMeta
| Field | Applies to | What |
|---|---|---|
filename | all | Required. Sent as the TUS filename metadata / the direct fileName. |
contentType | all | MIME type. TUS sends it as filetype; direct declares it at initiate, where a collection gate may reject it. |
size | all | Total byte length. Defaults to the blob's .size. |
key | proxy only | The object key. Ignored by TUS and direct, which derive the key server-side. |
disk | proxy only | Disk override. A direct upload's disk is assigned by the server at initiate. |
metadata | TUS only | Extra Upload-Metadata pairs carried through to your server. See Upload client. |
tusPath | TUS only | Per-upload override of the create path, for a TUS route that embeds a resource id. |
headers / getHeaders are merged into requests against your app only. Presigned S3 PUTs deliberately receive neither — adding an Authorization header there would break the SigV4 signature.
A mediaUrl(id, conversion?) helper is also exported for composing URLs against the base path.
Next steps
- Upload client — the full
MediaUploadClientsurface, the part transport, andMediaHttpError - Console launcher — open the media console from your app, in three tiers
- Direct sessions — the server contract the direct strategy speaks
- Resumable / TUS — the TUS protocol it implements
- Dashboard — reuses this client for its upload UI
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.
Upload Client
The full MediaUploadClient surface — the three upload strategies plus the session primitives underneath them, the injectable part transport, typed MediaHttpError, and the custom TUS metadata that reaches your server through parseTusMetadata.