😺

RemixでFirestore Storageへファイルアップロード機能を作る

2024/11/26に公開

はじめに

zip ファイルのアップロードとダウンロードが出来る Web アプリを作っていた。Remix 公式ドキュメントに Cloudinary へ画像アップロードするサンプルコードはありましたが、Firebase Storage へのものは無かったので、メモする。

環境

  • remix-run/{node,react}: ^2.15.0
  • firebase-admin: ^13.0.1

準備

import { Form } from "@remix-run/react";
import { initializeApp, cert } from 'firebase-admin/app';

// Firebase初期化
initializeApp({
  credential: cert({
    projectId: process.env.FIREBASE_PROJECT_ID,
    clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
    privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
  }),
  storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
});

export default function Index() {
  return (
    <Form
      method="post"
      encType="multipart/form-data"
    >
      <input type="file" name="file" id="" />
      <button type="submit">
        upload
      </button>
    </Form>
  );
}

Remix Utilities を使う方法

※try-catch は割愛

import type {
  ActionFunctionArgs,
  UploadHandler,
} from "@remix-run/node";
import {
  unstable_composeUploadHandlers as composeUploadHandlers,
  unstable_createFileUploadHandler as createFileUploadHandler,
  unstable_createMemoryUploadHandler as createMemoryUploadHandler,
  unstable_parseMultipartFormData as parseMultipartFormData,
  writeAsyncIterableToWritable
} from "@remix-run/node";
import { getStorage } from "firebase-admin/storage";

/**
 * @param data The iterable of the file bytes
 * @param name The name of the file in this bucket. (like images/rickroll.mp4)
 * @param expires Signed URL expiration
 * @returns
 */
async function uploadToFirebaseStorage(data: AsyncIterable<Uint8Array>, filename: string, expires?: number) {
  const storage = getStorage().bucket();
  const blob = storage.file(filename);
  const writableStream = blob.createWriteStream();
  await writeAsyncIterableToWritable(data, writableStream);
  const [url] = await blob.getSignedUrl({
    action: 'read',
    expires: expires ?? Date.now() + 60 * 60 * 1000,
  });

  return url
}

export const action = async ({ request }: ActionFunctionArgs) => {
  const uploadHandler: UploadHandler = composeUploadHandlers(
    async ({ name, contentType, data, filename }) => {
      if (name !== "file" || !filename) {
        return undefined;
      }

      // const fileExtension = filename.split(".").pop()?.toLowerCase();
      // if (!ALLOWED_FILE_EXTENSIONS.includes(fileExtension || "")) {
      // throw new Error("Invalid file extension");
      // }

      const signedUrl = await uploadToFirebaseStorage(
        data,
        filename,
        Date.now() + 60 * 60 * 1000
      );
      return signedUrl;
    },
    createMemoryUploadHandler()
  );

  const formData = await parseMultipartFormData(request, uploadHandler)
  const imageSrc = formData.get("file")

  return json({ imageSrc, error: null });
};

Remix Utilities を使わない方法

※try-catch は割愛

import type { ActionFunctionArgs } from "@remix-run/node";
import { Readable } from 'stream';
import { getStorage } from 'firebase-admin/storage';

export const action = async ({ request }: ActionFunctionArgs) => {
  const storage = getStorage().bucket();
  const form = await request.formData();
  const file = form.get("file") as File

  const fileStream = Readable.from(file.stream());
  const blob = storage.file(`images/${file.name}`);
  await new Promise((resolve, reject) => {
  fileStream.pipe(blob.createWriteStream())
    .on('finish', resolve)
    .on('error', reject)
  });

  const [url] = await blob.getSignedUrl({
    action: 'read',
    expires: Date.now() + 60 * 60 * 1000,
  });

  return json({ imgSrc: url, error: null });
};

さいごに

GitHubで編集を提案

Discussion