RemixでCloudflare R2にWeb Workerからアップロードしてみる

2023.10.06

まえがき

前回はFormから画像をアップロードしました。

今回はWeb Workerからアップロードします。 Formから直ファイルをアップロードしちゃうと、アップロードが終わるまでUIがロックされてしまったり、Fetcherからアップロードしても、画面遷移等でクリアされちゃったり、制御不能になりがちです。

画像のアップロード中Page遷移等されていてもアップロードが中断されないようにしたいのですが、今回はWeb Workerを使ってアップロードします。

今回はRemixから、そしてWorker自体もTypeScriptで書きたいので、名前のWeb Workerを使わずライブラリーをつかちゃいます。

@shopify/react-web-worker を使っていきます。

Web Workerの実行結果もawaitでとれるので、結構使いやすいです。

Web WorkerからCloudflare R2にWeb Workerアップロード

@shopify/react-web-worker をインストール

$ bun install @shopify/react-web-worker

アップロードするWeb Workerを実装

複数ファイルをアップロードできるようにします。今回は複数並列ではなく1件ずつファイルアップロードをします。

app/worker.ts

export async function uploads(key: string, files: FileList) {
  for (const [index, file] of Object.entries(files)) {
    await upload(`${key}_${index}`, file);
  }
  return { success: true };
}

export async function upload(key: string, file: File) {
  const data = new FormData();
  data.append("key", key);
  data.append("file", file);
  const response = await fetch("/api/upload", {
    method: "POST",
    body: data,
  });
  return await response.json();
}

uploadのAPIを実装

前回actionをつくったものを別Routeに切り出しました。またR2のkeyを指定できるようにしました。

app/routes/api.uploads.ts

import {
  type ActionFunctionArgs,
  json,
  unstable_createMemoryUploadHandler,
  unstable_parseMultipartFormData,
} from "@remix-run/cloudflare";

type AppEnv = {
  R2: R2Bucket;
};

export async function action({ request, context }: ActionFunctionArgs) {
  const env = context.env as AppEnv;
  const uploadHandler = unstable_createMemoryUploadHandler({
    maxPartSize: 1024 * 1024 * 10,
  });
  const form = await unstable_parseMultipartFormData(request, uploadHandler);
  const file = form.get("file") as Blob;
  const key = form.get("key") as string;
  const response = await env.R2.put(key, await file.arrayBuffer(), {
    httpMetadata: {
      contentType: file.type,
    },
  });
  return json({ object: response });
}

ファイルアップロードするページを作成

複数ファイルをアップロードしたいですが、D1などを使わないので何がアップロードしてるか覚えておく手段がないです。面倒なのでR2にアップロードしているファイル全部表示するようにしてます。

Submitボタンをクリックされたら、Web Workerでファイルアップロードしてます。これはPageなどをしてもキャンセルされず継続されます。

app/routes/upload.tsx

import { type LoaderFunctionArgs } from "@remix-run/cloudflare";
import { createWorkerFactory } from "@shopify/web-worker";
import { useWorker } from "@shopify/react-web-worker";
import { useState } from "react";
import { Link, useLoaderData } from "@remix-run/react";

type AppEnv = {
  R2: R2Bucket;
};

type Form = {
  fileList: FileList | null;
  key: string;
};
type ImageKey = {
  key: string;
};

export async function loader({ request, context }: LoaderFunctionArgs) {
  const env = context.env as AppEnv;
  const list = await env.R2.list();
  const images = list.objects.map((o) => {
    return {
      key: o.key,
    };
  });
  return { images };
}

const createWorker = createWorkerFactory(() => import("../worker"));

export default function Index() {
  const { images } = useLoaderData() as { images: ImageKey[] };
  const worker = useWorker(createWorker);
  const [form, setForm] = useState<Form>({ key: "", fileList: null });
  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (form.fileList == null || form.key.length === 0) {
      return;
    }
    worker.uploads(form.key, form.fileList);
  };

  return (
    <div>
      <div>
        <form method={"POST"} encType="multipart/form-data" onSubmit={onSubmit}>
          <input
            type="text"
            name={"key"}
            onChange={(e) => setForm({ ...form, key: e.target.value })}
          />
          <input
            type="file"
            name={"file"}
            multiple
            onChange={(e) => {
              setForm({ ...form, fileList: e.target.files });
            }}
          />
          <button type={"submit"}>送信</button>
        </form>
      </div>
      <div>
        {images.map((image) => {
          return (
            <Link to={"/"} key={image.key}>
                <img src={`/images/${image.key}`} alt="" width={100} height={100} />
            </Link>
          );
        })}
      </div>
    </div>
  );
}

ページ遷移でもアップロードがキャンセルされていないか確認するためのページ

app/routes/_index.tsx

export default function Index() {
  return (
    <div>
      Test Page
    </div>
  );
}

動作確認

ローカルで実行するとファイルアップロードが早すぎて、わかりずらいのでデプロイして確認します。

ページ遷移等しても画像アップロードが続いています。

まとめ

ソースコードがかなり簡易的で動かすだけのコードになってますが、このままでアップロード中のファイルを表示したり、アップロード済みのファイル数を表示したりをやっても以下の問題があります。

  • アップロード中に再度アップロードしたときの複数のWeb Workerが動いちゃう問題
  • グローバル変数を用いて管理しても、整合とれた数を表示できない問題
  • 一個ずつアップロードしても3並列ずつアップロードしたいなどに対応できない

アップロード中はアップロードボタン非活性にしてもいんだけど、追加でアップロードしたいですよね。

次はQueueを用いて、アップロード中はアップロードボタンを押されても大丈夫なようにつくってみたいと思います。