Open10

Cloudflare Meetup Nagoya #6 ハンズオン資料

aiji42aiji42

このハンズオンでやること

  • Cloudflare PagesにNext.jsをデプロイしてみる
  • Next.jsからBindings(R2)が使えるようにしてみる
  • R2を使って画像のアップロードサイトを作ってみる
  • (時間が余ったら) Workers AI を組み込んでみる

必要なもの

  • Cloudflareのアカウント(課金の必要はありません)
  • Node.jsが動くPC/Mac
    • Node.js v20以上を入れておいてください
aiji42aiji42

※このセクションは当日までにやっておくとスムーズです

Next.jsのプロジェクトを作ります。

npx create-next-app@latest

プロジェクト名などはご自由に。(ここではmy-nextjs-on-pagesとします)
Typescriptやディレクトリ構造など聞かれますが、デフォルトのままでOKです。

まずローカルでNext.jsが立ち上がることを確認してみましょう。

cd <作成したプロジェクトのディレクトリ>
npm run dev

↑のようなメッセージが表示されたら、http://localhost:3000にブラウザでアクセスしてみて見ましょう。


↑こんなページが表示されたらOK👏

ソースコード

aiji42aiji42

次にCloudflare Pagesにデプロイしてみます。
pagesのデプロイはwranglerによるコマンドラインからのデプロイと、Githubなどと連携して自動でpush & deploy する方法があります。
このハンズオンでは、wranglerによるデプロイ方法で進めます。

まず、専用のパッケージが必要なのでインストールします。

npm install -D @cloudflare/next-on-pages

続いて、プロジェクトルートにwrangler.tomlを作ってください。

# wrangler.toml
name = "my-nextjs-on-pages"
compatibility_date = "2024-08-13"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".vercel/output/static"

nameは自由に設定してOK(ですが、プロジェクトをデプロイするときにこの名前が使用されます)。
compatibility_flags = ["nodejs_compat"]は、忘れると正しく動かないので必ず書いてください。NodeランタイムとCloudflareのエッジランタイムの互換性を確保するために必要です。

さらに、package.jsonを開いてscripts

    "pages:build": "npx @cloudflare/next-on-pages",
    "preview": "npm run pages:build && wrangler pages dev",
    "deploy": "npm run pages:build && wrangler pages deploy"

を追加してください。

  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "pages:build": "npx @cloudflare/next-on-pages",
    "preview": "npm run pages:build && wrangler pages dev",
    "deploy": "npm run pages:build && wrangler pages deploy"
  },

こんな感じになっていればOK。


ここまで完了したら、wranglerから自身のCloudflareアカウントにログインします。

npx wrangler login


自動的にブラウザが立ち上がると思いますが、もし立ち上がらなかったらターミナルに表示されているURLにアクセスしてください。

アクセスしたページで 「Allow」をクリックします。

こんながページが表示されて、ターミナルにSuccessfully logged in.と表示されていればOK

一応wrangler whoamiコマンドで確認しておきましょう。

npx wrangler whoami

こんな感じで、自分のアカウントが表示されたらデプロイの準備は完了です。


早速デプロイしてみましょう。

npm run deploy

途中で↓のように聞かれるのでEnterを押しましょう。

The project you specified does not exist: "<自身が設定したname>". Would you like to create it?
❯ Create a new project

次に、productionとして扱うgitのブランチを聞かれます。デフォルトのmainでOKなので、そのままEnterを押します。

? Enter the production branch name: › main

こんな画面になったらOK!

早速、画面に表示されているURLにアクセスしたいところですが、名前解決ができるようになるまで少しだけ時間がかかります。

しばらく時間が立つと、先程ローカルで立ち上げたときと同じページが表示されるはずです。

ソースコード

aiji42aiji42

名前解決がされるようになるまでの待ち時間が暇な方は、Cloudflareのウェブコンソールを開いて見てみましょう。

デプロイしたPagesを開くと、URLが2種類表示されています。

  • <name>.pages.dev
    • productionに設定したブランチ(ここではmain)でデプロイしたもの
  • <hash>.<name>.pages.dev
    • デプロイのたびにプレビュー用のURLが発行される
aiji42aiji42

続いて Bindings を使って開発をしていきます。Bindings は KV や DO、R2、AI、D1 などのCloudflareが提供している便利なサービスで、コードからモジュールベースで接続ができるものです。

このハンズオンでは R2 を使用して、画像のアップロードサービスを作ってみます。

まず、R2の利用を宣言します。先ほど追加したwrangler.tomlに次の記述を追加してください。

# wrangler.toml
# ...省略
pages_build_output_dir = ".vercel/output/static"

# ここから下を追加
[[r2_buckets]]
binding = 'MY_BUCKET'
bucket_name = 'my-bucket'

bucket_nameは自由に設定して構いません(が、最終的にこの名前のバケットを自身のCloudflareアカウント内に作成することになります)。
もし自身のCloudflareアカウントにR2がすでにあるのであれば、それと被らないように設定することをおすすめします。


続いて、ローカルの開発環境で先ほど宣言した Bindings を利用できるようにします。

プロジェクトルートにnext.config.mjs(Next.jsの設定ファイル)があるので、次のようにファイルを変更します。

// next.config.mjs
// ここから下を追加
import { setupDevPlatform } from '@cloudflare/next-on-pages/next-dev';

if (process.env.NODE_ENV === 'development') {
  await setupDevPlatform();
}
// ここから上を追加

/** @type {import('next').NextConfig} */
// ...省略

これによって、開発用のサーバを立ち上げるときに、自動的にwrangler.tomlに宣言されたBindingsが利用できるようになります。


更にTypescriptの型による補完が効くようにします。

@cloudflare/workers-typesをインストールします。

npm install -D @cloudflare/workers-types

プロジェクトルートにあるtsconfig.jsoncompilerOptions内に"types": ["@cloudflare/workers-types"]を追加します。

// tsconfig.json
{
  "compilerOptions": {
    // ...省略
    "paths": {
      "@/*": ["./src/*"]
    },
    "types": ["@cloudflare/workers-types"] // これ
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

src/ディレクトリ配下にenv.d.tsというファイルを作り、次のように記述します。

// src/env.d.ts
interface CloudflareEnv {
  MY_BUCKET: R2Bucket
}

このMY_BUCKETの部分は、先程wrangler.tomlに追加したときのbinding = と一致している必要があります。

これで、Next.jsのコードから Bindings (R2) を利用する準備が整いました。


Bindings との接続が正しくできているか、デバッグしてみます。

src/app/page.tsxを次のように変更してみてください。(詳しくは後ほど説明します。)

// src/app/page.tsx
import Image from "next/image";
import { getRequestContext } from '@cloudflare/next-on-pages'

export const runtime = 'edge'

export default function Home() {
  console.log('MY_BUCKET', getRequestContext().env.MY_BUCKET)
  
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
    // 省略

getRequestContext().env.MY_...と入力している途中で、↓のようにエディタがサジェストを出してくれば、型の設定はうまくいっています。

さらに、この状態で開発環境を立ち上げて、localhost:3000にアクセスしてみます。

npm run dev

サーバのログを注意深く観察し、↓のように constructor { }と表示されていたら、無事Next.jsからR2の利用ができます。

もし、↓のようにundefinedとなっていたら、これまでの何処かで設定を間違えていると思われます。このセクションの一番上から、設定値が間違っていないかなど確認してください。

今変更したsrc/app/page.tsxはあくまでデバッグ用なので、完了したら元に戻しておいてください。

ソースコード

aiji42aiji42

途中解説

getRequestContext().env

Next.jsからBindingsを利用するときは、

import { getRequestContext } from "@cloudflare/next-on-pages";

getRequestContext().env.<設定したBinding名>

で対象のBindingをモジュールとして参照できます。
このハンズオンでは、wrangler.tomlにR2バケットをbinding = 'MY_BUCKET'として宣言したので、getRequestContext().env.MY_BUCKETでR2バケットを参照できます。次のように書くことでアップロードしたり、アップロード済みのオブジェクトを参照したりということが可能になります。

const bucket = getRequestContext().env.MY_BUCKET

// ファイルのアップロード
const data = formData.get("photo")
await bucket.put(data.name, data)

// ファイルの参照
const object  = await bucket.get("<object key>")

また、例えば次のように宣言すれば、

[[kv_namespaces]]
binding = 'STORE'
bucket_name = 'cache-kv'

[[d1_databases]]
binding = "DB"
database_name = "tutorial-d1"
database_id = "<unique-ID-for-your-database>"

getRequestContext().env.STOREでKVが参照可能になり、getRequestContext().env.DBでD1のデータベースが参照可能になります。

export const runtime = 'edge'

Next.jsはランタイムをNode.jsとエッジランタイムの2種類から選択可能です。

export const runtime = 'edge'

とページファルに記述しておくと、「このエンドポイントはエッジランタイムで実行してください」ということが宣言できます。
Bindings のコードはエッジランタイム依存なので、このあと追加するページではすべてこの宣言が必要です。

aiji42aiji42

それでは、R2の利用の準備が整ったので、R2を使った画像のアップロード・プレビューができるサイトを作ります。
こんな感じで3つのページから成るサイトを作ります。

  • /photos: アップロード済みの画像の一覧ページ
  • /photos/upload: 画像アップロード用ページ
  • /photos/preview?name=<画像名>: アップロードした画像のプレビューページ

まずは、アップロード済みのデータがほしいのアップロードページ(/photos/upload)から作っていきます。

src/app配下にphotos/upload/page.tsxを作成します。

// src/app/photos/upload/page.tsx
export const runtime = "edge";

const UploadPage = () => {
  return (
    <div className="p-4 space-y-2 min-h-screen">
      <h1 className="text-2xl font-bold">Upload</h1>
      <form className="bg-white dark:bg-slate-800 rounded-lg px-6 py-8 ring-1 ring-slate-900/5 shadow-xl">
        <input type="file" name="photo" required accept="image/*" />
        <button
          type="submit"
          className="bg-blue-500 text-white rounded-lg px-4 py-2"
        >
          Upload
        </button>
      </form>
    </div>
  );
};

export default UploadPage

開発環境を立ち上げ、http://localhost:3000/photos/uploadにアクセスし、次のようなページが表示されればOKです。

ファイルの選択は可能ですが、アップロードボタンをクリックしても何も起きません。

Server Actionsを使って、アップロードに対応した処理を追加します。

// src/app/photos/upload/page.tsx
export const runtime = "edge";

const UploadPage = () => {
  // server action
  const upload = async (formData: FormData) => {
    "use server";
    console.log(formData.get("photo"));
  };

  return (
    <div className="p-4 space-y-2 min-h-screen">
      <h1 className="text-2xl font-bold">Upload</h1>
      <form
        action={upload} // server actionを追加
        className="bg-white dark:bg-slate-800 rounded-lg px-6 py-8 ring-1 ring-slate-900/5 shadow-xl"
      >
        <input type="file" name="photo" required accept="image/*" />
       {/* 省略 */}

クライアントから受け取ったフォームデータからphotoという名前のデータを受け取り、console.logに表示しているだけです。

ファイルを選択してアップロードを実行してみると、開発サーバのログにこのように表示されるはずです。

server actions が動いていることが確認できたら、データをR2にアップロードできるようにします。

ファイルの先頭で、getRequestContextをimportし、

// src/app/photos/upload/page.tsx
import { getRequestContext } from "@cloudflare/next-on-pages";

export const runtime = "edge";

upload関数を次のように書き換えます。

  const upload = async (formData: FormData) => {
    "use server";

    const photo = formData.get("photo") as File;
    const bucket = getRequestContext().env.MY_BUCKET;

    await bucket.put(photo.name, photo);
    
    console.log("Uploaded!!", photo.name);
  };

再度、アップロードを実施てみましょう。
特にページ上の変化は現れませんが、開発サーバのログにこのように表示されているはずです。

そして、プロジェクトルートにある.wrangler/ディレクトリの中を確認してみてください。
このように、自身がwrangler.tomlに設定したバケット名のディレクトリ配下に、blobファイルが発生していればOKです。

開発環境では本物のR2バケットを利用するわけではなく、このように.wrangler/ディレクトリ配下を利用して再現されます。これはKVやD1などをローカルで利用する場合も同様です。

Gitで追跡する必要はないので、.gitignore.wranglerを追加しておきましょう。

# wrangler
.wrangler

これで一旦アップロードページの開発は完了です。

ソースコード

aiji42aiji42

続いてアップロードしたファイルを確認するための一覧ページと、プレビューページを追加してみます。

まずは、src/app/photos/配下にpreview/page.tsxを作成。
クエリパラメータとして渡されたname値でR2バケットからデータを取得して表示できるページを作ります。

// src/app/photos/preview/page.tsx
import { getRequestContext } from "@cloudflare/next-on-pages";
import { notFound } from "next/navigation";

export const runtime = "edge";

const PreviewPage = async ({
  searchParams,
}: {
  searchParams: { name?: string };
}) => {
  if (!searchParams.name) return notFound();

  const bucket = getRequestContext().env.MY_BUCKET;
  const data = await bucket.get(searchParams.name);
  if (!data) return notFound();

  const buffer = await data.arrayBuffer();

  return (
    <div className="p-4 space-y-2 min-h-screen">
      <h1 className="text-2xl font-bold">Preview</h1>
      <div className="relative w-fit">
        <img
          src={`data:image/png;base64,${Buffer.from(buffer).toString("base64")}`}
          alt=""
          className="object-cover rounded-lg shadow-xl w-auto"
        />
        <span className="absolute bottom-1 right-1 bg-gray-500 opacity-50 text-white text-xs px-1 rounded">
          {Math.round(buffer.byteLength / 1024)} KB
        </span>
      </div>
    </div>
  );
};

export default PreviewPage;

続いて、アップロードページのuploadアクションを変更して、アップロード完了後にプレビューページにリダイレクトがかかるように変更します。

// src/app/photos/upload/page.tsx
import { getRequestContext } from "@cloudflare/next-on-pages";
import { redirect } from "next/navigation"; // 追加

export const runtime = "edge";

const UploadPage = () => {
    const upload = async (formData: FormData) => {
        "use server";
    
        const photo = formData.get("photo") as File;
        const bucket = getRequestContext().env.MY_BUCKET;
    
        await bucket.put(photo.name, photo);
    
        console.log("Uploaded!!", photo.name);
    
        // これを追加
        return redirect(`/photos/preview?name=${encodeURIComponent(photo.name)}`);
      };

// 省略

アップロード後に自動的にプレビューページに遷移できたらOK


続いてsrc/app/photos/配下にpage.tsxを作成。
バケット内の画像の一覧を表示するリストページを作成します。

// src/app/photos/page.tsx
import { getRequestContext } from "@cloudflare/next-on-pages";
import Link from "next/link";

export const runtime = "edge";

const ListPage = async () => {
  const bucket = getRequestContext().env.MY_BUCKET;
  const { objects } = await bucket.list();

  return (
    <div className="p-4 space-y-2 min-h-screen">
      <h1 className="text-2xl font-bold">List</h1>
      <ul className="space-y-2">
        {objects.map((object) => (
          <li key={object.key} className="flex items-center space-x-2">
            <Link
              href={`/photos/preview?name=${object.key}`}
              className="text-blue-500 underline"
            >
              {object.key}
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default ListPage;

このように、コンポーネントの中に直接バックエンドの処理を書けるのが、Next.js App Routerの魅力です。

ソースコード

aiji42aiji42

ここまできたら、デプロイしてみましょう。

デプロイの手順は、最初に一度行った手順のとおりですが、R2バケットはまだCloudflareアカウント上には作成されていないので、別途作成する必要があります。

wranglerコマンドでR2を作成します。

# my-bucket は適宜、自身のwrangler.tomlに書いたbucket_nameに置き換えてください
npx wrangler r2 bucket create my-bucket

このように表示されたらOK

これで、デプロイの準備が整ったのでデプロイしてみます。

npm run deploy

こんな感じでデプロイできたらOK。

/photos/uploadにアクセスして、画像のアップロードを試してみてください。

aiji42aiji42

ここから先は時間が余った人用

せっかくなので、他のBindingsも利用してみましょう。
AI Bindingsのimage-to-textモデルを使用して、アップロードした画像の概要をAIに出力させてみます。

wrangler.tomlでAIを使用することを宣言します。

# wrangler.toml
# ...省略
bucket_name = 'my-bucket'

# ↓を追加
[ai]
binding = "AI"

src/env.d.tsにAIという名前でAI Bindingsを利用するための型を登録します。

// src/env.d.ts
interface CloudflareEnv {
  MY_BUCKET: R2Bucket;
  AI: Ai; // 追加
}

これで getRequestContext().env.AI でAI Bindingsが利用できるようになりました。


画像のデータを受け取って、AIに説明を求めるためのコンポーネントをpreview/page.tsx内に記述します。

// src/app/photos/preview/page.tsx

// ↓をファイル内に追記する
const Description = async ({ buffer }: { buffer: ArrayBuffer }) => {
  const ai = getRequestContext().env.AI
  const input = {
    image: [...new Uint8Array(buffer)],
    prompt: "Generate a caption for this image",
    max_tokens: 512,
  };
  const response = await ai.run("@cf/llava-hf/llava-1.5-7b-hf", input);

  return <p className="text-sm">{response.description}</p>;
};

DescriptionコンポーネントをPreviewPageコンポーネント内に配置します。

// src/app/photos/preview/page.tsx

const PreviewPage = async ({
  searchParams,
}: {
  searchParams: { name?: string };
}) => {
  if (!searchParams.name) return notFound();

  const bucket = getRequestContext().env.MY_BUCKET;
  const data = await bucket.get(searchParams.name);
  if (!data) return notFound();

  const buffer = await data.arrayBuffer();

  return (
    <div className="p-4 space-y-2 min-h-screen">
      <h1 className="text-2xl font-bold">Preview</h1>
      <div className="relative w-fit">
        <img
          src={`data:image/png;base64,${Buffer.from(buffer).toString("base64")}`}
          alt=""
          className="object-cover rounded-lg shadow-xl w-auto"
        />
        <span className="absolute bottom-1 right-1 bg-gray-500 opacity-50 text-white text-xs px-1 rounded">
          {Math.round(buffer.byteLength / 1024)} KB
        </span>
      </div>
      {/* ここにDescriptionコンポーネントを追加 */}
      <Description buffer={buffer} />
    </div>
  );
};

開発サーバを立ち上げて、プレビューページを開いてみます。

こんな感じで、画像の説明が生成されていれば成功です。

しかし、AI Bindingsからレスポンスが返ってくるまでの間、ページがレンダリングできないので遷移に時間がかかるようになってしまいました...

Suspenseを使用してDescriptionコンポーネント以外の部分を先に返却するようにしてあげます。

// src/app/photos/preview/page.tsx
import { getRequestContext } from "@cloudflare/next-on-pages";
import { notFound } from "next/navigation";
import { Suspense } from "react"; // 追加

export const runtime = "edge";

const PreviewPage = async ({
  searchParams,
}: {
  searchParams: { name?: string };
}) => {
  if (!searchParams.name) return notFound();

  const bucket = getRequestContext().env.MY_BUCKET;
  const data = await bucket.get(searchParams.name);
  if (!data) return notFound();

  const buffer = await data.arrayBuffer();

  return (
    <div className="p-4 space-y-2 min-h-screen">
      <h1 className="text-2xl font-bold">Preview</h1>
      <div className="relative w-fit">
        <img
          src={`data:image/png;base64,${Buffer.from(buffer).toString("base64")}`}
          alt=""
          className="object-cover rounded-lg shadow-xl w-auto"
        />
        <span className="absolute bottom-1 right-1 bg-gray-500 opacity-50 text-white text-xs px-1 rounded">
          {Math.round(buffer.byteLength / 1024)} KB
        </span>
      </div>
      {/* Suspenseで囲う */}
      <Suspense fallback={<p className="animate-pulse">Generating description...</p>}>
        <Description buffer={buffer} />
      </Suspense>
    </div>
  );
};

export default PreviewPage;

const Description = async ({ buffer }: { buffer: ArrayBuffer }) => {
  const input = {
    image: [...new Uint8Array(buffer)],
    prompt: "Generate a caption for this image",
    max_tokens: 512,
  };
  const response = await getRequestContext().env.AI.run(
    "@cf/llava-hf/llava-1.5-7b-hf",
    input,
  );

  return <p className="text-sm">{response.description}</p>;
};

こんな感じで、画像の説明を生成している間は、Generating description...と表示されるようになりました。

ソースコード