Aviary
Recipes

Resumable uploads from the browser

Wire the tus server and upload large files directly from React with progress and resume.

For large files, accept them through the resumable tus path — chunked, with progress, and resilient to a dropped connection — then attach the finished object to an entity.

Enable the tus server

app.module.ts
import { InMemoryUploadSessionStore } from '@dudousxd/nestjs-media-testing';

MediaModule.forRoot({
  default: 's3',
  disks: { s3 },
  uploadSessions: new InMemoryUploadSessionStore(), // swap for a durable store in prod
  tus: {
    disk: 's3',
    basePath: '/media/uploads',
    maxSize: 2 * 1024 * 1024 * 1024, // 2 GB
    keyFor: (filename, token) => `incoming/${token}/${filename}`,
  },
});
main.ts
app.use('/media/uploads', express.raw({ type: 'application/offset+octet-stream', limit: '20mb' }));

Upload from React

upload-form.tsx
import { useMediaUpload } from '@dudousxd/nestjs-media-react';

export function UploadForm() {
  const { upload, status, progress, location } = useMediaUpload({ basePath: '/media/uploads' });

  return (
    <label>
      <input
        type="file"
        onChange={async (e) => {
          const file = e.target.files?.[0];
          if (file) await upload(file, { filename: file.name, contentType: file.type });
        }}
      />
      {status === 'uploading' && <progress value={progress} max={1} />}
      {status === 'done' && <span>Uploaded → {location}</span>}
    </label>
  );
}

Or drop in the ready-made component:

import { MediaUploader } from '@dudousxd/nestjs-media-react';

<MediaUploader basePath="/media/uploads" accept="video/*" onUploaded={(loc) => save(loc)} />

Attach the finished upload

The upload's location ends in the session id; the bytes are already at the keyFor path on the disk. The client POSTs the finished upload's details to your API, which records it against the entity:

posts.controller.ts
import { Body, Controller, Param, Post } from '@nestjs/common';
import { MediaService } from '@dudousxd/nestjs-media';

@Controller('posts/:id/photos')
export class PostPhotosController {
  constructor(private readonly media: MediaService) {}

  @Post('finalize')
  finalize(
    @Param('id') postId: string,
    @Body() body: { uploadedKey: string; fileName: string; mimeType: string },
  ) {
    return this.media.library.attach({
      ownerType: 'Post', ownerId: postId, collection: 'gallery',
      fileName: body.fileName, mimeType: body.mimeType, disk: 's3',
      contents: this.media.disk('s3').stream(body.uploadedKey), // re-key into the library layout
    });
  }
}

Production session store

InMemoryUploadSessionStore is fine for a single instance / dev. Behind multiple instances, implement UploadSessionStore against Redis or your DB so an upload can resume on whichever node the next chunk lands on.

Forcing the path

On an S3 disk, uploadMode defaults to direct (presigned). To run this flow through the backend instead — say, to virus-scan on the way in — force proxy per the uploads concept; the same React client works unchanged.

True direct-to-S3: presigned multipart

The tus flow above is resumable proxy — the bytes still pass through Nest. For the highest throughput, skip the backend entirely: the browser PUTs each part straight to S3 against a presigned multipart URL. Your server only orchestrates the upload; it never sees the bytes, so it never becomes the bandwidth bottleneck.

Enable the direct controller

Pass the direct option an S3 disk. This mounts MediaDirectUploadController at media/uploads/direct and exposes the orchestrator as media.directUploads:

app.module.ts
MediaModule.forRoot({
  default: 's3',
  disks: { s3 },
  direct: {
    disk: 's3',
    partSize: 8 * 1024 * 1024, // optional — default 8 MiB
  },
});

No raw-body parser, no session store: S3 holds the in-progress multipart upload keyed by the uploadId it returns.

Initiate, PUT each part, complete (browser)

initiate returns one presigned URL per part. The browser PUTs each slice directly to S3, collects the ETag header from each response, then tells the server to assemble them. This is plain browser code:

direct-upload.ts
async function directUpload(file: File) {
  // 1. Initiate — server opens the multipart upload and presigns each part
  const init = await fetch('/media/uploads/direct/initiate', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ key: `videos/${file.name}`, contentType: file.type, size: file.size }),
  }).then((r) => r.json()); // → { uploadId, key, disk, partSize, parts: [{ partNumber, url }] }

  // 2. PUT each part straight to S3, capturing the ETag it returns
  const parts = await Promise.all(
    init.parts.map(async ({ partNumber, url }) => {
      const start = (partNumber - 1) * init.partSize;
      const blob = file.slice(start, start + init.partSize);
      const res = await fetch(url, { method: 'PUT', body: blob });
      return { partNumber, etag: res.headers.get('etag')! };
    }),
  );

  // 3. Complete — S3 stitches the parts into the final object
  await fetch(`/media/uploads/direct/${init.uploadId}/complete`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ key: init.key, parts }),
  });

  return init.key;
}

If a presigned URL expires mid-upload, mint a fresh one for that single part with POST /media/uploads/direct/:uploadId/parts/:partNumber?key=…; abandon the whole upload with DELETE /media/uploads/direct/:uploadId?key=….

Attach the finished object

The bytes are already at key on the disk once complete returns — record it against your entity exactly as in the proxy flow, or drive the manager yourself from a service:

videos.controller.ts
import { Body, Controller, Param, Post } from '@nestjs/common';
import { MediaService } from '@dudousxd/nestjs-media';

@Controller('posts/:id/videos')
export class PostVideosController {
  constructor(private readonly media: MediaService) {}

  @Post('finalize')
  finalize(@Param('id') postId: string, @Body() body: { key: string; fileName: string; mimeType: string }) {
    return this.media.library.attach({
      ownerType: 'Post', ownerId: postId, collection: 'videos',
      fileName: body.fileName, mimeType: body.mimeType, disk: 's3',
      contents: this.media.disk('s3').stream(body.key),
    });
  }
}

proxy vs direct, in one line

Use proxy/tus when you need to touch the bytes (virus-scan, transcode, watermark) or support disks that can't presign; use direct multipart when raw throughput matters and the disk is S3. See Uploads & multipart for the full comparison and the DirectUploadManager engine.

On this page