Cloudflare Meetup Nagoya #6 ハンズオン資料
このハンズオンでやること
- Cloudflare PagesにNext.jsをデプロイしてみる
- Next.jsからBindings(R2)が使えるようにしてみる
- R2を使って画像のアップロードサイトを作ってみる
- (時間が余ったら) Workers AI を組み込んでみる
必要なもの
- Cloudflareのアカウント(課金の必要はありません)
- Node.jsが動くPC/Mac
- Node.js v20以上を入れておいてください
※このセクションは当日までにやっておくとスムーズです
Next.jsのプロジェクトを作ります。
npx create-next-app@latest
プロジェクト名などはご自由に。(ここではmy-nextjs-on-pages
とします)
Typescriptやディレクトリ構造など聞かれますが、デフォルトのままでOKです。
まずローカルでNext.jsが立ち上がることを確認してみましょう。
cd <作成したプロジェクトのディレクトリ>
npm run dev
↑のようなメッセージが表示されたら、http://localhost:3000
にブラウザでアクセスしてみて見ましょう。
↑こんなページが表示されたらOK👏
次に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にアクセスしたいところですが、名前解決ができるようになるまで少しだけ時間がかかります。
しばらく時間が立つと、先程ローカルで立ち上げたときと同じページが表示されるはずです。
名前解決がされるようになるまでの待ち時間が暇な方は、Cloudflareのウェブコンソールを開いて見てみましょう。
デプロイしたPagesを開くと、URLが2種類表示されています。
- <name>.pages.dev
- productionに設定したブランチ(ここではmain)でデプロイしたもの
- <hash>.<name>.pages.dev
- デプロイのたびにプレビュー用のURLが発行される
続いて 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.json
のcompilerOptions
内に"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
はあくまでデバッグ用なので、完了したら元に戻しておいてください。
途中解説
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 のコードはエッジランタイム依存なので、このあと追加するページではすべてこの宣言が必要です。
それでは、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
これで一旦アップロードページの開発は完了です。
続いてアップロードしたファイルを確認するための一覧ページと、プレビューページを追加してみます。
まずは、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の魅力です。
ここまできたら、デプロイしてみましょう。
デプロイの手順は、最初に一度行った手順のとおりですが、R2バケットはまだCloudflareアカウント上には作成されていないので、別途作成する必要があります。
wrangler
コマンドでR2を作成します。
# my-bucket は適宜、自身のwrangler.tomlに書いたbucket_nameに置き換えてください
npx wrangler r2 bucket create my-bucket
このように表示されたらOK
これで、デプロイの準備が整ったのでデプロイしてみます。
npm run deploy
こんな感じでデプロイできたらOK。
/photos/upload
にアクセスして、画像のアップロードを試してみてください。
ここから先は時間が余った人用
せっかくなので、他の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...
と表示されるようになりました。