10.27.2022

Visualforceから(ContentVersionに)大容量ファイルをアップロードする

50MB をアップロードする機能要件が必須だったため試行錯誤。
Blob データを挿入または更新するから、REST API でマルチパートメッセージなら 2GB まで大丈夫らしい。
下記のようなリクエストボディを作る必要がある。

--boundary_string
Content-Disposition: form-data; name="entity_content";
Content-Type: application/json

{
    "ContentDocumentId" : "069D00000000so2",
    "ReasonForChange" : "Marketing materials updated",
    "PathOnClient" : "Q1 Sales Brochure.pdf"
}

--boundary_string
Content-Type: application/octet-stream
Content-Disposition: form-data; name="VersionData"; filename="Q1 Sales Brochure.pdf"

Binary data goes here.

--boundary_string--

最初は、new FormData()でボディを作ろうとしたけど Blob 以外はうまく渡せず断念。
Binary data goes here.に、アップロードしたいファイルのバイナリデータをそのまま挟んであげないといけない。
その前後の文字列はTextEncoderで Uint8Array に変換、挟み込むバイナリデータも Uint8Array にして 3 つのバイナリを一つにしてリクエストしてあげれば成功する感じ。

const uploadFile = async (
  file_name_with_ext: ContentVersion['PathOnClient'],
  description: ContentVersion['Description'],
  reference_id: ContentVersion['FirstPublishLocationId'],
  content_type: Blob['type'],
  data: ArrayBuffer,
) => {
  const te = new TextEncoder()
  const res = await axios.post(
    `/services/data/v53.0/sobjects/ContentVersion`,
    new Uint8Array([
      ...te.encode(`--boundary_string
Content-Disposition: form-data; name="entity_content";
Content-Type: application/json

${JSON.stringify({
  PathOnClient: file_name_with_ext,
  Description: description,
  FirstPublishLocationId: reference_id,
})}

--boundary_string
Content-Type: ${content_type}
Content-Disposition: form-data; name="VersionData"; filename="${file_name_with_ext}"

`),
      ...new Uint8Array(data),
      ...te.encode(`
--boundary_string--`),
    ]).buffer,
    {
      headers: {
        Authorization: `Bearer ${session_id}`, // {!$Api.Session_ID}とか
        'Content-Type': `multipart/form-data; boundary=\"boundary_string\"`,
      },
    },
  )

  const result: { id: string; success: boolean; errors: unknown[] } = res.data
  return result.id
}

使い方は下のイメージ。

const changeInputFile = async (_: React.ChangeEvent<HTMLInputElement>) => {
  const datas = await Promise.all(
    Array.from(_.target.files!).map(
      _ =>
        new Promise((resolve: ({ file, binary_data }: { file: File; binary_data: ArrayBuffer }) => void) => {
          const r = new FileReader()
          r.onload = () => resolve({ file: _, binary_data: r.result as ArrayBuffer })
          r.readAsArrayBuffer(_)
        }),
    ),
  )

  for (const d of datas) {
    await uploadFile(d.file.name, 'test', 'some_salesforce_id', d.file.type, d.binary_data)
  }
}

理論上は 2GB だけど Chrome だと 100MB 少し超えたあたりでクラッシュ。
その他ブラウザ、マシンスペックでもアップロードできるサイズは変わってきそう。