Comparison of S3 upload feature between Documenso and aws-s3-image-upload example

In this article, we will compare the steps involved to upload a file to AWS S3 between Documenso and AWS S3 image upload example.

We start with the simple example provided by Vercel.

examples/aws-s3-image-upload

Vercel provides a good working example of uploading a file to AWS S3.
This example’s README provides two options, either you can use an existing S3 bucket or create a new bucket. Understanding this helps
you configure your upload feature correctly.

It is about the time we look at the source code. We are looking for an input element with type=file. In the app/page.tsx, you will find this below code:

return (
    <main>
      <h1>Upload a File to S3</h1>
      <form onSubmit={handleSubmit}>
        <input
          id="file"
          type="file"
          onChange={(e) => {
            const files = e.target.files
            if (files) {
              setFile(files[0])
            }
          }}
          accept="image/png, image/jpeg"
        />
        <button type="submit" disabled={uploading}>
          Upload
        </button>
      </form>
    </main>
  )
}

onChange

onChange updates state using setFile, but it does not do the uploading. upload happens when you submit this form.

onChange={(e) => {
  const files = e.target.files
  if (files) {
    setFile(files[0])
  }
}}

handleSubmit

A lot is happening in the handleSubmit function. We need to analyse the list of operations in this handleSubmit function. I have written the comments inside this code snippet to explain the steps.

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()

    if (!file) {
      alert('Please select a file to upload.')
      return
    }

    setUploading(true)

    const response = await fetch(
      process.env.NEXT_PUBLIC_BASE_URL + '/api/upload',
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ filename: file.name, contentType: file.type }),
      }
    )

    if (response.ok) {
      const { url, fields } = await response.json()

      const formData = new FormData()
      Object.entries(fields).forEach(([key, value]) => {
        formData.append(key, value as string)
      })
      formData.append('file', file)

      const uploadResponse = await fetch(url, {
        method: 'POST',
        body: formData,
      })

      if (uploadResponse.ok) {
        alert('Upload successful!')
      } else {
        console.error('S3 Upload Error:', uploadResponse)
        alert('Upload failed.')
      }
    } else {
      alert('Failed to get pre-signed URL.')
    }

    setUploading(false)
  }

api/upload

api/upload/route.ts has the below code:

import { createPresignedPost } from '@aws-sdk/s3-presigned-post'
import { S3Client } from '@aws-sdk/client-s3'
import { v4 as uuidv4 } from 'uuid'

export async function POST(request: Request) {
  const { filename, contentType } = await request.json()

  try {
    const client = new S3Client({ region: process.env.AWS_REGION })
    const { url, fields } = await createPresignedPost(client, {
      Bucket: process.env.AWS_BUCKET_NAME,
      Key: uuidv4(),
      Conditions: [
        ['content-length-range', 0, 10485760], // up to 10 MB
        ['starts-with', '$Content-Type', contentType],
      ],
      Fields: {
        acl: 'public-read',
        'Content-Type': contentType,
      },
      Expires: 600, // Seconds before the presigned post expires. 3600 by default.
    })

    return Response.json({ url, fields })
  } catch (error) {
    return Response.json({ error: error.message })
  }
}

The first request in the handleSubmit was to /api/upload and sends content type and filename as payload. It is parsed as below:

const { filename, contentType } = await request.json()

Next step is to create a S3 client and then create a presigned post that returns url and field. You would use this url to upload your file.

With this knowledge, let’s analyse how the upload works in Documenso and draw some comparison.

PDF file upload in Documenso

Let’s start with the input element with type=file. Code is organized differently in Documenso. You would find input element in a file named document-dropzone.tsx.

<input {...getInputProps()} />

<p className="text-foreground mt-8 font-medium">{_(heading[type])}</p>

Here getInputProps is returned useDropzone. Documenso uses react-dropzone.

import { useDropzone } from 'react-dropzone';

onDrop calls props.onDrop, you will find an attribute value named onFileDrop in upload-document.tsx.

<DocumentDropzone
  className="h-[min(400px,50vh)]"
  disabled={remaining.documents === 0 || !session?.user.emailVerified}
  disabledMessage={disabledMessage}
  onDrop={onFileDrop}
  onDropRejected={onFileDropRejected}
/>

Let’s see what happens in onFileDrop function.

const onFileDrop = async (file: File) => {
    try {
      setIsLoading(true);

      const { type, data } = await putPdfFile(file);

      const { id: documentDataId } = await createDocumentData({
        type,
        data,
      });

      const { id } = await createDocument({
        title: file.name,
        documentDataId,
        teamId: team?.id,
      });

      void refreshLimits();

      toast({
        title: _(msg`Document uploaded`),
        description: _(msg`Your document has been uploaded successfully.`),
        duration: 5000,
      });

      analytics.capture('App: Document Uploaded', {
        userId: session?.user.id,
        documentId: id,
        timestamp: new Date().toISOString(),
      });

      router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
    } catch (err) {
      const error = AppError.parseError(err);

      console.error(err);

      if (error.code === 'INVALID_DOCUMENT_FILE') {
        toast({
          title: _(msg`Invalid file`),
          description: _(msg`You cannot upload encrypted PDFs`),
          variant: 'destructive',
        });
      } else if (err instanceof TRPCClientError) {
        toast({
          title: _(msg`Error`),
          description: err.message,
          variant: 'destructive',
        });
      } else {
        toast({
          title: _(msg`Error`),
          description: _(msg`An error occurred while uploading your document.`),
          variant: 'destructive',
        });
      }
    } finally {
      setIsLoading(false);
    }
  };

There’s a lot of happening but for our analysis, let’s only consider the function named putFile.

putPdfFile

putPdfFile is defined in upload/put-file.ts

/**
 * Uploads a document file to the appropriate storage location and creates
 * a document data record.
 */
export const putPdfFile = async (file: File) => {
  const isEncryptedDocumentsAllowed = await getFlag('app_allow_encrypted_documents').catch(
    () => false,
  );

  const pdf = await PDFDocument.load(await file.arrayBuffer()).catch((e) => {
    console.error(`PDF upload parse error: ${e.message}`);

    throw new AppError('INVALID_DOCUMENT_FILE');
  });

  if (!isEncryptedDocumentsAllowed && pdf.isEncrypted) {
    throw new AppError('INVALID_DOCUMENT_FILE');
  }

  if (!file.name.endsWith('.pdf')) {
    file.name = `${file.name}.pdf`;
  }

  removeOptionalContentGroups(pdf);

  const bytes = await pdf.save();

  const { type, data } = await putFile(new File([bytes], file.name, { type: 'application/pdf' }));

  return await createDocumentData({ type, data });
};

putFile

This calls putFile function.

/**
 * Uploads a file to the appropriate storage location.
 */
export const putFile = async (file: File) => {
  const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');

  return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
    .with('s3', async () => putFileInS3(file))
    .otherwise(async () => putFileInDatabase(file));
};

putFileInS3

const putFileInS3 = async (file: File) => {
  const { getPresignPostUrl } = await import('./server-actions');

  const { url, key } = await getPresignPostUrl(file.name, file.type);

  const body = await file.arrayBuffer();

  const reponse = await fetch(url, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/octet-stream',
    },
    body,
  });

  if (!reponse.ok) {
    throw new Error(
      `Failed to upload file "${file.name}", failed with status code ${reponse.status}`,
    );
  }

  return {
    type: DocumentDataType.S3_PATH,
    data: key,
  };
};

getPresignPostUrl

export const getPresignPostUrl = async (fileName: string, contentType: string) => {
  const client = getS3Client();

  const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');

  let token: JWT | null = null;

  try {
    const baseUrl = APP_BASE_URL() ?? 'http://localhost:3000';

    token = await getToken({
      req: new NextRequest(baseUrl, {
        headers: headers(),
      }),
    });
  } catch (err) {
    // Non server-component environment
  }

  // Get the basename and extension for the file
  const { name, ext } = path.parse(fileName);

  let key = `${alphaid(12)}/${slugify(name)}${ext}`;

  if (token) {
    key = `${token.id}/${key}`;
  }

  const putObjectCommand = new PutObjectCommand({
    Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
    Key: key,
    ContentType: contentType,
  });

  const url = await getSignedUrl(client, putObjectCommand, {
    expiresIn: ONE_HOUR / ONE_SECOND,
  });

  return { key, url };
};

Comparison

  • You do not see any POST request in Documenso. It uses a function named getSignedUrl to get the url, whereas
    vercel example makes a POST request to api/upload route.

  • Input element can be located easily in Vercel example as this is just an example, but Documenso is found
    to be using react-dropzone and the input element is located according to business context.

About us:

At Thinkthroo, we study large open source projects and provide architectural guides. We have developed reusable Components, built with tailwind, that you can use in your project.

We offer Next.js, React and Node development services.

Book a meeting with us to discuss your project.

References:

  1. https://github.com/documenso/documenso/blob/main/packages/lib/universal/upload/put-file.ts#L69

  2. https://github.com/vercel/examples/blob/main/solutions/aws-s3-image-upload/README.md

  3. https://github.com/vercel/examples/tree/main/solutions/aws-s3-image-upload

  4. https://github.com/vercel/examples/blob/main/solutions/aws-s3-image-upload/app/page.tsx#L58C5-L76C12

  5. https://github.com/vercel/examples/blob/main/solutions/aws-s3-image-upload/app/api/upload/route.ts

  6. https://github.com/documenso/documenso/blob/main/packages/ui/primitives/document-dropzone.tsx#L157

  7. https://react-dropzone.js.org/

  8. https://github.com/documenso/documenso/blob/main/apps/web/src/app/(dashboard)/documents/upload-document.tsx#L61

  9. https://github.com/documenso/documenso/blob/main/packages/lib/universal/upload/put-file.ts#L22