Uploads & multipart
Resumable proxy uploads (tus) vs direct presigned uploads, the uploadMode that picks between them, and the engine underneath.
Small files are easy — multipart/form-data, one request, done. Large files are where upload code earns its keep: a 500 MB video shouldn't restart from zero because a phone changed cell towers, and it shouldn't necessarily stream every byte through your Node event loop either. media does both shapes, and the choice is always yours to override.
The two paths
proxy — bytes flow through your NestJS backend in chunks via a real tus 1.0.0 server. The upload is resumable: a dropped connection continues from the last acknowledged offset instead of starting over. Works on any disk (even local), and because the bytes pass through you, it's where you'd virus-scan, watermark, or transform on the way in.
direct — the backend only orchestrates; the browser uploads chunks straight to the storage backend using presigned S3 multipart. Your server never sees the bytes, so it never becomes the bandwidth bottleneck. This is the right default at scale, but it requires a disk that can presign.
uploadMode: choosing per upload
resolveUploadMode decides, reading the driver's capabilities:
type UploadMode = 'auto' | 'proxy' | 'direct';auto(default) →directwhen the driver is presign/multipart-capable (S3), otherwiseproxy(local). The sensible default for most apps: scale where you can, proxy where you can't.proxy→ always allowed; every driver can accept bytes throughput.direct→ throws on a driver that can't presign, rather than silently degrading.
Resolution is most-specific-wins:
per-call ▸ per-disk ▸ global ▸ autoThe override is the point
Even on an S3 disk that could go direct, you can force proxy for a specific upload — e.g. user-supplied images you want to virus-scan or re-encode on the way in — and force direct elsewhere for raw throughput. The driver's capability sets the default; you keep the final say. This is exactly the override choice: the disk proposes, the call disposes.
Resumable (tus) server
Enable the proxy path by giving the module an upload session store, and mount the tus controller with the tus option:
Configure
import { InMemoryUploadSessionStore } from '@dudousxd/nestjs-media-testing';
MediaModule.forRoot({
default: 'local',
disks: { local },
uploadSessions: new InMemoryUploadSessionStore(), // or your own UploadSessionStore
uploadTmpPrefix: '.uploads', // where in-progress chunks live (default)
tus: { disk: 'local', basePath: '/media/uploads', maxSize: 100 * 1024 * 1024 },
});This mounts MediaUploadController, implementing the tus creation + termination protocol (OPTIONS / POST / HEAD / PATCH / DELETE) at media/uploads.
No built-in auth
MediaUploadController (and MediaDirectUploadController for the direct path) ship without any authentication or authorization guard — anyone who can reach media/uploads (or media/uploads/direct) can create, append to, and abort uploads. Protect these routes yourself: apply a NestJS guard (e.g. a global APP_GUARD, or a route-prefix guard/middleware on media/uploads) appropriate to your app.
Add a raw-body parser
tus PATCH bodies are application/offset+octet-stream — register a raw parser so they arrive as Buffers:
import express from 'express';
app.use('/media/uploads', express.raw({ type: 'application/offset+octet-stream', limit: '50mb' }));Without this, PATCH bodies won't be Buffers and chunks won't assemble. This is the single most common setup mistake.
Upload from the browser
The React package and the codegen client both ship uploadMedia() — it POSTs to create the upload, PATCHes chunks with offset tracking, resumes on failure, and reports progress:
import { uploadMedia } from '@dudousxd/nestjs-media-client';
const { location } = await uploadMedia(file, {
filename: file.name,
contentType: file.type,
basePath: '/media/uploads',
onProgress: (sent, total) => setPct(sent / total),
});What the server does
On POST it creates a session (reading Upload-Length and Upload-Metadata), on PATCH it appends a chunk at the declared Upload-Offset (returning the new offset, or 409 on mismatch, 415 on the wrong content type), and when the offset reaches the declared length it assembles the parts into the final object automatically. HEAD reports the current offset so a client can resume; DELETE aborts and cleans up.
The engine underneath
Both the HTTP handler and the browser client sit on top of ResumableUploadManager (framework-agnostic):
const session = await media.uploads.createUpload({ disk: 's3', key: 'videos/clip.bin', size });
await media.uploads.writeChunk(session.id, 0, chunkA); // → { offset }
await media.uploads.writeChunk(session.id, offset, chunkB);
const { key } = await media.uploads.complete(session.id); // assembles + cleans up parts
// media.uploads.status(id) to resume; media.uploads.abort(id) to cancelEach chunk is written immediately as a part on the target disk (under uploadTmpPrefix), so resuming needs no in-memory buffering and a half-finished upload survives a process restart. complete concatenates the parts into the final object and removes them. Reach media.uploads directly when you want to drive uploads from your own protocol or a non-HTTP transport.
Direct (presigned multipart) server
The direct path inverts the data flow: the browser PUTs each part straight to S3 against a presigned URL, and your backend only orchestrates — it never sees a byte. Enable it with the direct option (it needs a presign/multipart-capable disk, i.e. S3):
MediaModule.forRoot({
default: 's3',
disks: { s3 },
direct: {
disk: 's3',
partSize: 8 * 1024 * 1024, // optional, default 8 MiB
},
});This mounts MediaDirectUploadController at media/uploads/direct and provides DirectUploadManager as media.directUploads. The lifecycle is four calls:
| Step | HTTP | Manager method |
|---|---|---|
| initiate | POST media/uploads/direct/initiate | createUpload — opens an S3 multipart upload and returns one presigned URL per part |
| presign a part | POST media/uploads/direct/:uploadId/parts/:partNumber | presignPart — mint a fresh URL for a single part (e.g. retry an expired one) |
| complete | POST media/uploads/direct/:uploadId/complete | completeUpload — assemble the parts (S3 stitches them server-side) |
| abort | DELETE media/uploads/direct/:uploadId | abortUpload — discard the multipart upload and its parts |
initiate takes { key, contentType?, size?, partSize?, disk? }. When size is known it returns ceil(size / partSize) presigned part URLs up front; otherwise it returns a single URL and you mint the rest on demand via the parts endpoint. Presigned URLs expire after presignExpirySeconds (default 3600). No raw-body parser is needed — the bytes never touch Nest.
No session store required
The direct path is stateless on your side: S3 holds the in-progress multipart upload, keyed by the uploadId it returns. The UploadSessionStore (and its Redis adapter) is only for the proxy/tus path, where your backend tracks the resume offset.
The engine underneath
Both the controller and any custom transport sit on DirectUploadManager (framework-agnostic, also reachable as media.directUploads):
const { uploadId, parts, partSize } = await media.directUploads.createUpload({
disk: 's3', key: 'videos/clip.mp4', contentType: 'video/mp4', size,
}); // parts: [{ partNumber, url }, …]
// browser PUTs each part to its url, collects the returned ETag, then:
await media.directUploads.completeUpload({
key: 'videos/clip.mp4', uploadId,
parts: [{ partNumber: 1, etag: '"abc…"' }, /* … */],
});
// media.directUploads.abortUpload({ key, uploadId }) to cancelcreateUpload throws UnsupportedOperationError if the target disk can't presign multipart — so forcing direct fails loudly rather than silently degrading.