0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Next.js × Hono】タイピングゲームのAPIをCloudflare Workersにデプロイする

0
Posted at

はじめに

たびたびJISOUさんの記事で学ばせていただいていますが、今回はこちらのタイピングゲームに挑戦しました。

タイピングゲームそのものは、当該記事をご参照いただくとして、この記事の最後にあります「課題」の部分、これについて取り組んだことを備忘録を兼ねて記事にしてみました。

課題

  1. APIをCloudflare Workersにデプロイする
  2. リザルト画面でタイプミス回数を表示する
  3. ランキング機能で最下位5名も表示

ここまでの技術スタック

Frontend

  • Framework: Next.js(App Router)
  • Language: TypeScript
  • Styling: Tailwind CSS

Backend

  • Framework: Hono
  • Database: Upstash Redis

1. APIをCloudflare Workersにデプロイする

Cloudflare Workersとは?

Cloudflareのエッジネットワーク上でJavaScript/TypeScriptコードを実行できるサーバーレス環境で、ユーザーに近い場所で処理できるため低レイテンシを狙いやすいのが特徴

ということです。こちらの記事がわかりやすかったです。

あらかじめCloudflareでアカウントを作っておく必要があります。「Free」プランでOK。

wranglerのインストール

wranglerとは、Cloudflare Workersの開発、ビルド、デプロイを支援するためのコマンドラインツールです。


typing-game % bun add -d wrangler

パッケージマネージャーはbunを使用しています。

wrangler init の実行。基本的なフォルダ構成が作られます。

typing-game % bunx wrangler init

このあと、設定のためにいろいろと質問されます。


In which directory do you want to create your application? also used as application name
# どのディレクトリに作成しますか? → workersと入力 

What would you like to start with?
# どれから始めますか?
● Hello World example
○ Framework Starter
○ Application Starter
○ Template from a GitHub repo
◁ Go back
→ Hello Worldを選択

Which template would you like to use?
# どのテンプレートを使いますか?
● Worker only
○ Static site
○ SSR / full-stack app
○ Worker + Durable Objects
○ Worker + Durable Objects + Assets
○ Workflow
○ Scheduled Worker (Cron Trigger)
○ Queue consumer & producer Worker
○ API starter (OpenAPI compliant)
◁ Go back
→ Workers onlyを選択

Which language do you want to use?
# どの言語を使いますか?
● TypeScript
○ JavaScript
○ Python (beta)
→ TypeScriptを選択

Do you want to add an AGENTS.md file to help AI coding tools understand Cloudflare APIs?Yes / No
# AGENTS.md(AIコーディングツール向けの補助ファイル)が必要か?
→ Noを選択

You're in an existing git repository. Do you want to use git for version control?
# gitバージョン管理をしますか?
→ Yesを選択

Do you want to deploy your application? Yes / No
# デプロイしますか?
→ Noを選択。あとで実行するため。

🎉  SUCCESS  Application created successfully!

今回は、上記の順で進めましたが、順番を逆にもできるようで、
  bunx wrangler init、cd workers、bun add -d wrangler
の順で進めると、workersフォルダの中にwrangler依存を入れられるので
キレイになると思われます。

Cloudflareアカウントとの連携

# wrangler loginの実行
typing-game % bunx wrangler login

ブラウザに認証画面が現れるので、認証を許可して成功すると以下のような画面になります。
スクリーンショット 2026-02-13 22.01.43.png

デプロイの実行

bunx wrangler deployを実行すると、いくつか質問を聞かれます。

# workersディレクトリに移動します
typing-game % cd workers

# wrangler deploy実行
workers % bunx wrangler deploy

# 実行結果
 ⛅️ wrangler 4.65.0
───────────────────
Total Upload: 18.23 KiB / gzip: 4.58 KiB
Worker Startup Time: 22 ms
Uploaded workers (4.14 sec)
▲ [WARNING] You need to register a workers.dev subdomain before publishing to workers.dev

? Would you like to register a workers.dev subdomain now? › (Y/n)
→ Yesを選択:workersを公開するのにxxxx.workers.devというURLのサブドメインの登録が必要

What would you like your workers.dev subdomain to be? It will be accessible at https://<subdomain>.workers.dev … {サブドメイン名}
→ サブドメイン名を入力 

? Creating a workers.dev subdomain for your account at https://{サブドメイン名}.workers.dev. Ok to proceed? › (Y/n)
→ Yesを選択

Success! It may take a few minutes for DNS records to update.
# 管理画面へのリンク
Visit https://dash.cloudflare.com/(*****************) to edit your workers.dev subdomain
Deployed workers triggers (298.14 sec)
  # 自分のWorkerの公開URL
  https://workers.{サブドメイン名}.workers.dev
# 今回デプロイされたWorkerの「バージョン識別子」。デプロイするたびに変わる
Current Version ID: ***********************
 

workersにHono、Upstash Redisをインストール

一度、以下の記事に沿ってtyping-gameを作っているので、Next.jsのルートディレクトリにはHono、Upstash Redisとも、インストールしてはいたのですが、バックエンドがworkersの中で動くので、あらためてインストールします。

# workersに移動
typing-game % cd workers

# Hono、 @upstash/redisをインストール
workers % bun add hono @upstash/redis

Cloudflare Workersに環境変数を設定

CloudflareのDashboard画面の以下の場所で登録が可能。

Compute > Workers&Pages > workers(該当のデプロイデータ)を選択 > 設定 > 変数とシークレット

私は管理画面から登録しましたが、ドキュメントによればコマンドでも登録することができます。

npx wrangler secret put <KEY>

参考ドキュメント

proxyの設定

Next.jsのrewritesを使って/api/*へのリクエストを WorkersのURLへ転送します。

import type { NextConfig } from "next";

const workerBase = process.env.WORKER_API_BASE;

const nextConfig: NextConfig = {
  /* config options here */
  /* 以下を追加 */
  async rewrites() {
    return [
      {
        source: "/api/:path*",
        destination: `${workerBase}/:path*`,
      },
    ];
  }
};

export default nextConfig;

destinationはworkersのURLを直書きでもいいと思いますが、私は変数にしています。

2. リザルト画面でタイプミス回数の表示、ランキング機能で最下位5名も表示

事前にフロントエンド、バックエンド共通で使う型を別ファイルで定義しています。

shared/types.ts
export type Score = {
  userName: string;
  score: number;
};

export type ResultResponse = {
  bests: Score[];
  worsts: Score[];
};

workersのコード実装

元々はapp/api/[[...route]]/route.tsにあったコードをworkers/src/index.tsに移植し、ランキング下位5位までのデータ取得をAPIに追加します。

workers/src/index.ts

import { Redis } from '@upstash/redis';
import { Hono } from 'hono';
import type { Score, ResultResponse } from "../../shared/types";


type Bindings = {
	UPSTASH_REDIS_REST_URL: string;
	UPSTASH_REDIS_REST_TOKEN: string;
};

type CreateScoreBody = {
	userName: string,
	score: number,
}
// Honoでの書き方
const app = new Hono<{ Bindings: Bindings }>();

// 動作確認用
app.get('/ping', (c) => {
	return c.text('pong');
});

// スコアデータの登録
app.post('/result', async (c) => {
	try {
		// リクエストボディからスコアとユーザー名を取得
		const { score, userName } = await c.req.json<CreateScoreBody>();

		if (score === undefined || userName === undefined || userName === "") {
			return c.json({ error: 'Missing score or userName' }, 400);
		}

		// Redisクライアントを初期化
		const redis = new Redis({
			url: c.env.UPSTASH_REDIS_REST_URL,
			token: c.env.UPSTASH_REDIS_REST_TOKEN,
		});

		const result = {
			score: score,
			member: userName,
		};
        // 注)zadd:下記参照
		await redis.zadd('typing-score-rank', result);

		return c.json({
			message: 'Score submitted successfully',
		});
	} catch (e) {
		return c.json({ error: `Error: ${String(e)}` }, 500);
	}
});

// スコアランキング結果の取得
app.get('/result', async (c) => {
	try {
		const redis = new Redis({
			url: c.env.UPSTASH_REDIS_REST_URL,
			token: c.env.UPSTASH_REDIS_REST_TOKEN,
		});

        // 上位5位までの取得
		const bests = await redis.zrange('typing-score-rank', 0, 4, {
			rev: true,
			withScores: true,
		});

		const bestScores: Score[] = [];
		for (let i = 0; i < bests.length; i += 2) {
			bestScores.push({
				userName: String(bests[i]),
				score: Number(bests[i + 1]),
			});
		}

		// 下位5位までを取得
		const worsts = await redis.zrange('typing-score-rank', 0, 4, {
			withScores: true,
		})

		const worstScores: Score[] = [];
		for (let i = 0; i < worsts.length; i += 2) {
			worstScores.push({
				userName: String(worsts[i]),
				score: Number(worsts[i + 1]),
			});
		}

		const response: ResultResponse = { bests: bestScores, worsts: worstScores };

		return c.json(response);
	} catch (e) {
		return c.json({ message: `Error: ${String(e)}`, 500 });
	}
});

// workersではこれ
export default app;

注)Sorted Setはmember(ここではユーザー名)とscore(点数)のペアを保持します。zaddで追加でき、同じmemberを再度登録するとスコアが更新されます。

workersのデプロイ

# workersディレクトリに移動
typing-game % cd workers

# デプロイ実行 
workers % bunx wrangler deploy

app/page.tsxの修正

タイプミス(typo)の回数表示とTop5、Bottom5の表示を実装します。

app/page.tsx
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import type { Score, ResultResponse } from "../shared/types";

export default function Home() {
  const questions = [
    { question: "React", image: "/monster1.jpg" },
    { question: "TypeScript", image: "/monster2.jpg" },
    { question: "JISOU", image: "/monster3.jpg" },
    { question: "GitHub", image: "/monster4.jpg" },
    { question: "Next.js", image: "/monster5.jpg" },
  ];

  const [currentQuestionIndex, setCurrentQuestionIndex] = useState<number>(0);
  const [currentPosition, setCurrentPosition] = useState<number>(0);
  const [isCompleted, setIsCompleted] = useState<boolean>(false);
  const [isStarted, setIsStarted] = useState<boolean>(false);
  const [userName, setUserName] = useState<string>("");
  const [startTime, setStartTime] = useState<number>(0);
  const [totalTime, setTotalTime] = useState<number>(0);
  const [score, setScore] = useState<number>(0);
  // 以下3行追加
  const [bestScores, setBestScores] = useState<Score[]>([]);
  const [worstScores, setWorstScores] = useState<Score[]>([]);
  const [typoCount, setTypoCount] = useState<number>(0);


  const bgmRef = useRef<HTMLAudioElement | null>(null);
  const shotSoundRef = useRef<HTMLAudioElement | null>(null);
  
  // 一部省略

  // Top5、Bottom5のデータを取得
  const fetchScores = async (): Promise<ResultResponse> => {
    const res = await fetch("/api/result");
    return await res.json();
  };

  useEffect(() => {
    const handleKeyDown = async (e: KeyboardEvent) => {
      const currentQuestion = questions[currentQuestionIndex];
      const exactChar = currentQuestion.question[currentPosition];
      // isStarted: ゲーム開始前の入力欄でのキー入力を無視するため
      if (isStarted && exactChar && e.key.toLowerCase() === exactChar.toLowerCase()) {
        // 入力した文字が合っている場合に、nextPositionを作る
        const nextPosition = currentPosition + 1;
        setCurrentPosition(nextPosition);

        // 問題の最後の文字まで終わった時
        if (nextPosition === currentQuestion.question.length) {
          // 最後の問題まで終わった時
          if (currentQuestionIndex === questions.length - 1) {
            if (shotSoundRef.current) {
              shotSoundRef.current.currentTime = 0;
              shotSoundRef.current.play();
            }
            const { totalTime, score } = await addResult(userName, startTime);
            setTotalTime(totalTime);
            setScore(score);
            setIsCompleted(true);
            // Top5、Bottom5を取り出し、stateに入れる
            const { bests, worsts } = await fetchScores();
            setBestScores(bests);
            setWorstScores(worsts);
          } else {
            if (shotSoundRef.current) {
              shotSoundRef.current.currentTime = 0;
              shotSoundRef.current.play();
            }
            setCurrentQuestionIndex((prev) => prev + 1);
            setCurrentPosition(0);
          }
        }
      } else if (isStarted && exactChar) {
        // タイプミスの場合、カウント+1
        setTypoCount((prev) => prev + 1);
      }
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
    // 依存配列にisStartedを追加
  }, [isStarted, currentPosition, currentQuestionIndex]);

  const handleStart = () => {
    if (!userName) {
      alert("名前を入力してください");
      return;
    }
    setIsStarted(true);
    // ゲームスタート時に時刻を保存
    setStartTime(Date.now());
  };

  // ゲームスタート前
  if (!isStarted) {
    return (
      <main className="flex min-h-screen flex-col items-center justify-center bg-black">
      {/* userName入力画面 省略 */}
      </main>
    );
  }

  // ゲーム終了後
  if (isCompleted) {
    return (
      <main className="flex min-h-screen flex-col items-center justify-center bg-black text-white">
        <div className="text-center p-8 bg-black/50 rounded-lg border border-red-800 shadow-2xl max-w-2xl w-full">
          <h2
            className="text-4xl font-bold mb-6 text-red-600"
            style={{ textShadow: "0 0 10px rgba(255, 0, 0, 0.7)" }}
          >
            Result
          </h2>
          <div className="mb-8 space-y-2">
            <p className="text-xl">
              Player: <span className="text-red-500">{userName}</span>
            </p>
            <p className="text-xl">
              Time:{" "}
              <span className="text-red-500">
                {(totalTime / 1000).toFixed(2)}
              </span>{" "}
              seconds
            </p>
            <p className="text-xl">
              Score: <span className="text-red-500">{score}</span>
            </p>
            {/* 追加:タイプミス回数の表示 */}
            <p className="text-xl">
              Typo Count: <span className="text-red-500">{typoCount}</span>
            </p>
          </div>
          {/* Top5の表示 */}
          <div className="mb-8">
            <h3 className="text-2xl font-bold mb-4 text-red-600">Top 5</h3>
            {bestScores.length === 0 ? (
              <div className="flex flex-col items-center justify-center py-8">
                <div className="w-12 h-12 border-4 border-red-600 border-t-transparent rounded-full animate-spin"></div>
                <p className="mt-4 text-red-500 animate-pulse">
                  Loading scores...
                </p>
              </div>
            ) : (
              <div className="space-y-4">
                {bestScores.map((score, index) => (
                  <div
                    key={index}
                    className="flex justify-between items-center p-3 bg-black/30 border border-red-900/50 rounded"
                  >
                    <span
                      className={`text-lg ${score.userName === userName ? "text-red-500" : ""}`}
                    >
                      {index + 1}.{score.userName}
                    </span>
                    <span className="text-red-500">{score.score}</span>
                  </div>
                ))}
              </div>
            )}
          </div>
          {/* 追加:Bottom5の表示 */}
          <div className="mb-8">
            <h3 className="text-2xl font-bold mb-4 text-red-600">Bottom 5</h3>
            {worstScores.length === 0 ? (
              <div className="flex flex-col items-center justify-center py-8">
                <div className="w-12 h-12 border-4 border-red-600 border-t-transparent rounded-full animate-spin"></div>
                <p className="mt-4 text-red-500 animate-pulse">
                  Loading scores...
                </p>
              </div>
            ) : (
              <div className="space-y-4">
                {worstScores.map((score, index) => (
                  <div
                    key={index}
                    className="flex justify-between items-center p-3 bg-black/30 border border-red-900/50 rounded"
                  >
                    <span
                      className={`text-lg ${score.userName === userName ? "text-red-500" : ""}`}
                    >
                      {index + 1}.{score.userName}
                    </span>
                    <span className="text-red-500">{score.score}</span>
                  </div>
                ))}
              </div>
            )}
          </div>
        </div>
      </main>
    );
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-between">
    {/* ゲーム画面 省略 */}
    </main>
  );
}

Vercelへのデプロイ

Next.jsをVercelにデプロイしますが、
Nextのビルド時にTypeScriptがworkers/src/index.tsまで見に行って、エラーになることがあるので、その回避策として、Next側tsconfig.jsonの"exclude"に"workers/**"を追加しています。

tsconfig.json

"exclude": ["node_modules", "workers/**"]

Result画面はこのような形。

スクリーンショット 2026-02-17 19.01.19.png

おわりに

Cloudflareへのデプロイがかなりややこしくなったので、もともとの課題の意図に合致しているのかわかりませんが、Cloudflare、Hono、Upstash Redisなどこれまで触れる機会がなかったものに触れることができ、また、state管理への理解も深めることができました。

参考までに、ディレクトリ構造とGitHubを貼っておきます。

  • typing-gameディレクトリ構造
.
├── app/                  # Next.js App Router source code
│   ├── page.tsx          # Main game logic and UI
│   └── layout.tsx        # Root layout
├── public/               # Static assets (images, sounds)
├── workers/              # Cloudflare Workers source code
│   ├── src/
│   │   └── index.ts      # API endpoints (Hono)
│   └── wrangler.jsonc    # Wrangler configuration
└── shared/               # Shared types between frontend and backend
  • GitHubリポジトリ

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?