Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Enable external uploader progress data #3663

Open
wants to merge 6 commits into
base: main
Choose a base branch
from

Conversation

keatz55
Copy link
Contributor

@keatz55 keatz55 commented Feb 5, 2025

It'd be nice if the client could push additional progress-related data to the server during external uploads. Adding a progress_data field to the Phoenix.LiveView.UploadEntry struct would enable LiveView to support client-side S3-compatible multipart uploads without extra server requests. This would allow the client to collect part_number and etag for each uploaded part and make them available when consume_uploaded_entries/3 is invoked to complete the multipart upload. Currently, the only way to send etags or any additional uploader data to the server is by hijacking hook events or making separate requests.

Below is an example of what this PR code can enable:

Form:

defmodule MyForm do
  @moduledoc false
  ...

  @impl true
  def handle_params(_params, _url, socket) do
    {:noreply,
     socket
     |> allow_upload(:big_file, accept: :any, external: &presign_upload/2)
     |> assign_new(:form, fn ->
       ...
     end)}
  end

  ...

  def handle_event("save", %{"form" => params}, socket) do
    uploaded_files =
      consume_uploaded_entries(socket, :big_file, fn %{upload_id: upload_id}, entry ->
        # Extracts part_number + etag from progress data
        parts = Enum.map(entry.progress_data["parts"], &{&1["part_number"], &1["etag"]})

        # Completes the multipart upload after the external uploader finishes uploading each part
        @bucket
        |> ExAws.S3.complete_multipart_upload(entry.client_name, upload_id, Enum.sort_by(parts, &elem(&1, 0)))
        |> ExAws.request()
        |> case do
          {:ok, %{status_code: 200} = res} -> {:ok, entry.client_name}
          {:error, error} -> {:error, error}
          result -> {:error, result}
        end
      end)

    ...
  end

  ...

  # Initiates multipart upload and generates all part presigned urls beforehand
  defp presign_upload(entry, socket) do
    part_count = ceil(entry.client_size / @chunk_size)

    @bucket
    |> ExAws.S3.initiate_multipart_upload(entry.client_name)
    |> ExAws.request()
    |> case do
      {:ok, %{body: %{upload_id: upload_id}}} ->
        presigned_urls = Enum.map(1..part_count, &generate_presigned_urls(entry.client_name, upload_id, &1))
        {:ok, %{presigned_urls: presigned_urls, uploader: "S3", upload_id: upload_id}}

      result ->
        result
    end
  end

  defp generate_presigned_urls(path, upload_id, part_number) do
    opts = [query_params: [uploadId: upload_id, partNumber: part_number]]
    {:ok, url} = ExAws.S3.presigned_url(ExAws.Config.new(:s3), :put, @bucket, path, opts)
    url
  end
end

Uploader:

export default async (entries, onViewError) => {
  entries.forEach(async (entry) => {
    const totalParts = Math.ceil(entry.file.size / CHUNK_SIZE);
    const parts = [];

    const results = await Promise.all(
      [...Array(totalParts).keys()].map(async (i) => {
        const start = i * CHUNK_SIZE;
        const end = Math.min(start + CHUNK_SIZE, entry.file.size);
        const chunk = entry.file.slice(start, end);
        const presignedUrl = entry.meta.presigned_urls[i];

        const res = await fetch(presignedUrl, { method: "PUT", body: chunk });

        if (res.ok) {
          const etag = res.headers.get("ETag");
          parts.push({ part_number: i + 1, etag: etag });
          const percent = Math.round((parts.length / totalParts) * 100);
          // Now supports pushing data along w/ progress percent to server
          entry.progress(percent, { ...entry._progressData, parts });
        } else {
          ...
        }
      })
    );
  });
};

@keatz55 keatz55 marked this pull request as draft February 5, 2025 20:16
@keatz55 keatz55 marked this pull request as ready for review February 6, 2025 17:28
# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant