Open15

Remix on Cloudflare Pages で何か動かす

Toshihiro ShimizuToshihiro Shimizu
$ npx create-remix@latest

で、Just the basicsCloudflare Pages を選択して雛形作成
Remix のバージョンは 1.3.4 だった。

npm run dev するとなぜか @remix-run/server-runtime が見つからないと言われるので、npm install @remix-run/server-runtime してやると動くようになった

Toshihiro ShimizuToshihiro Shimizu

<Outlet> の外側に <header><h1>タイトル</h1></header> がある構造のときに、タイトルをページに合わせて変える方法がわからない。
loader で request.url からパス調べて、パスに応じたタイトルを渡す、でやってるけどもっといい方法ありそう

root.tsx
export const loader: LoaderFunction = async ({ request }) => {
  const url = new URL(request.url);
  if (url.pathname === "/") {
    return json({title: "Videos"});
  } else if (url.pathname === "/upload") {
    return json({title: "Upload"});
  }
  return json({ title: "表示されないはず" });
}

export default function App() {
  const { title } = useLoaderData();
  return (
    <html lang="ja">
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <header>
          <h1>{title}</h1>
        </header>
        <Outlet />
        <LiveReload />
      </body>
    </html>
  );
}
Toshihiro ShimizuToshihiro Shimizu

環境変数の設定もちょっと情報少なくて苦労した。
Remix かつ Cloudflare Pages (Wrangler) の場合なので、他の環境の場合は別の方法が取れる。
Remix のバージョンと Wrangler のバージョン次第では、これでもうまく動かなかったこともあるが、Remix 1.3.4 と Wrangler 0.0.24 だととりあえずローカル(npm run dev)で動いた。

まず、環境変数を .env に書く。ファイルの場所はルートディレクトリ(package.json とかがあるのと同じところで、npm run devを実行する場所)。

.env
API_KEY=hogehogehoe

次に Wrangler 実行時のこの環境変数を読み込めるように package.json を修正する

package.json
"dev:wrangler": "cross-env NODE_ENV=development wrangler pages dev ./public -b $(cat .env)",

環境変数を使う際には、LoaderFunction の引数 context から取得する

index.tsx
export const loader: LoaderFunction = async (context) => {
  const env = context.context;
  console.log(env.API_KEY); // hogehogehoge が出力される
}
Toshihiro ShimizuToshihiro Shimizu

Cloudflare Stream の API の叩き方について

API を叩くためには、アカウントIDAPIトークン が必要。
アカウントIDは、Cloudflare Stream の管理画面の右上に書いてある。
APIキーはマイプロフィールのAPIトークンから、アカウントの Stream にアクセス許可をした新しくAPIトークンを作成する。

また、動画再生用の署名付きURLを発行するために必要な pem や jwk を取得するにはこちらの手順を参考に。

Toshihiro ShimizuToshihiro Shimizu

ページ内の TSX に Script を埋め込みたいときには、root.tsx<Scripts /> の記述をしておく必要がある。

videos/index.tsx
{videos.map((video) => (
  <div key={video.id} onClick={() => {window.location.href='/videos/' + video.id}} className="...">...</div>
))}

みたいなことをしたいときに、root.tsx

root.tsx
import { Scripts } from "remix";
...
export default function App() {
  return (
    <html>
      <body>
        ...
        <Outlet />
        <Scripts />
        <LiveReload />
      </body>
    </html>
  );
}

みたいにして、 <Scripts /> を記述しておかないと動作しない。

Toshihiro ShimizuToshihiro Shimizu

ここまでローカルでやってきたので、そろそろ一回 Cloudflare Pages にデプロイしてみたいと思う。
Cloudflare の管理画面で Pages 選んで、プロジェクト新規作成して、 GitHub のリポジトリと連携する。
フレームワークは Remix が選択肢にあったので選ぶと、Build Command とかはちゃんと Remix 用のになってくれる。
あとは環境変数を設定してデプロイ実行すればOK。

アクセス制限したければ、プロジェクトの設定から Access ポリシーで制限すれば、デフォルトで Cloudflare のアカウントにメールアドレスが登録されてるメンバーのみに限定される(アクセスしたらメール入力欄があり、メールでログインコードが届く)。

特に引っかかるところ無くスムーズにデプロイできて動作確認までできた。

Toshihiro ShimizuToshihiro Shimizu

↑で制限されるのはあくまでも プレビュー環境 であり、サブドメイン *.hogehoge.pages.dev に対して Access ポリシーだった(最初やったときはこれで本番の hogehoge.pages.dev も制限された気がしたんだけど、勘違いか仕様かわったか分からない)。

本番にもやる場合、Cloudflare Zero Trust の Access の Applications の設定からサブドメインの * の指定を外して空欄にする。

ただしそうすると、今度はプレビュー環境が見えてしまうので、もう一度↑のとおりに Pages のプロジェクト設定から制限すればOK。

Toshihiro ShimizuToshihiro Shimizu

さらにアクセス制限を、会社 Slack のアカウント持ってる人に限定しようと思う。

Cloudflare Zero Trust の Settings の Authentication から Add New で Identity Provider を選べる。
OpenID Connect で Slack とつなげたいので、OpenID Connect を選ぶ。

入力欄がでてくるが、まずは Slack で新規 App を作成し、OAuth & Permissions の Redirect URLs に Cloudflare のページの右側に載ってるURL https://<your-team-name>.cloudflareaccess.com/cdn-cgi/access/callback を登録する。<your-team-name> は Cloudflare Zero Trust の Settings の General で確認できる。

あとは、Slack の App の管理画面の Basic Information に Client ID, Client Secret が載ってるので、それぞれ App ID, Client secret に入力。あとの3つのURLの情報は、ここにある。

Slack の App 管理画面で、Scope も openid, profile, email をつけておしまい。

Toshihiro ShimizuToshihiro Shimizu

Remix のバージョンを 1.6.3 にあげた。
あわせて、React のバージョンも 17.0.2 にあげた。

import {hoge} from "remix" だったのを import {hoge} from "@remix-run/cloudflare" とか @remix-run/react とかに直せばだいたいOKだった。

Toshihiro ShimizuToshihiro Shimizu

個別の動画のページを作る。埋め込みプレーヤーも使う。

まずはプレーヤー。Cloudflare が用意してくれてる React 用のプレーヤーがあるのでそれを使う。
ただ再生するだけなら、src にVideoIDか署名付きIDを書けばいいだけ。

今回は、「10秒進む/戻る」の実装がしたかったので、プレーヤーに外から(プレーヤーは iframe の中にあるので)アクセスする必要がある。streamRef 属性を使うことで実現できる。

こんなイメージ.tsx
import { useLoaderData } from "@remix-run/react"
import { Stream, StreamPlayerApi } from "@cloudflare/stream-react";
import { useRef } from "react";

export default function VideoRoute() {
  const video = useLoaderData<VideoData>();
  const ref = useRef<StreamPlayerApi>();
  return (
    <main>
      <div>
        <Stream streamRef={ref} controls src={video.signedId} />
      </div>
      <div>
        <button onClick={() => {
          if (ref.current) {
           const now = ref.current.currentTime;
           ref.current.currentTime = now - 10;
         }}>&#xAB; 10秒戻る</button>
      </div>
    </main>
  )
}
Toshihiro ShimizuToshihiro Shimizu

次は動画のアップロード。Cloudflare Stream ではアップロードする方法はいくつかあるんだけど、CGMっぽくユーザーにアップロードしてもらう感じのことをしたかったら Direct creator uploads を使うべきっぽい。
かつ、ファイルサイズが200MBを超えるなら、普通にアップロードじゃなくて TUS っていうプロトコルでアップロードしないといけない。正直めんどくさい。

Direct Creator Uploads の手順としては、まず、ワンタイムのアップロードURL(endpoint)をゲットする。これはAPIトークンが必要なので、サーバーサイドで実行する。その後、ゲットした endpoint をクライアントサイドに渡して実際のアップロードを実行してもらう。こんな感じ。

ただし、TUSの場合はちょっとややこしくて、最初の endpoint をゲットする部分をAPI化して、そのAPIをクライアント側の tus-js-client ライブラリを使って呼び出す、って感じにしないといけない。
ファイルサイズとかメタデータとかが Request の header に入ってるので、それを取り出して endpoint を取得する API を叩いて、ゲットした endpoint を Response の Location Header に入れて返す。そんなAPIを作る。

upload/upload.tsx
import { json, ActionFunction } from "@remix-run/cloudflare";

export const action: ActionFunction = async ({ context, request }) => {
  const env = context;
  const size = request.headers.get("Upload-Length");
  const metadata = request.headers.get("Upload-Metadata");
  if (!size || typeof size !== "string" || size.length === 0 || 
      !metadata || typeof metadata !== "string" || metadata.length === 0) {
    return json({errors: {title: "File error."}}, {status: 422});
  }

  return new Response(null, {
    headers: {
      'Location': await getOneTimeEndpoint(size, metadata)
    }
  });

  async function getOneTimeEndpoint(size: string, metadata: string) {
    const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${env.ACCOUNT_ID}/stream?direct_user=true`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${env.STREAM_API_TOKEN}`,
        'Tus-Resumable': '1.0.0',
        'Upload-Length': size,
        'Upload-Metadata': metadata,
      },
    });
    const endpoint = response.headers.get('Location');
    if (!endpoint) return "";
    return endpoint;
  }
};
Toshihiro ShimizuToshihiro Shimizu

そのAPIを呼び出す側は、<input type="file" /> でファイルを取得して、そのファイルと meta 情報のオプションを添えて tus の Upload を呼び出す感じ。

upload/index.tsx
function upload() {
  const options = {
    endpoint: '/upload/upload',
    chunkSize: 50 * 1024 * 1024,
    metadata: {
      name: file.name,
      maxDurationSeconds: 3600,
      requireSignedURLs: true,
    },
    onError(error: Error) {
      console.log(error);
    },
    onSuccess() {
      console.log("Upload Completed!");
    },
    onProgress(bytesUploaded: number, bytesTotal: number) {
      const percentage = ((bytesUploaded / bytesTotal) * 100).toFix(2) + "%";
      console.log(percentage);
    }
  };
  const upload = new tus.Upload(file, options);
  upload.start();
}

endpoint に指定するのが、最初に取得したワンタイムなアップロードURLなのかと勘違いしがち(僕も勘違いしたし、検索するとみんな勘違いしてた)だけど、そうではなく自分で作った API の URL なので注意。

あと、tus-js-client は、Remix の tsx ファイル上で import * as tus from "tus-js-client" をしたら、起動時になんかめっちゃエラーでるので、import はせずに <script src="~~/tus.min.js" /> で読み込んでる。
動くけど、VSCode 上では tus が未定義だってエラーになる。しょうがない。

Toshihiro ShimizuToshihiro Shimizu

後はアップロード中にパーセンテージに合わせてプログレスバー作ったりとか、見た目調整したりしておわり。お疲れさまでした。