Closed14

Next.js × Honoプロジェクトをturborepoで作成する

miyamyiamiyamyia

このページを参考に構築していく。
https://turborepo.com/docs/getting-started/installation
パッケージマネージャーはpnpmを使用。

とりあえず、turboをグローバルインストールした方がいいみたいなので、以下コマンドでインストール

pnpm install turbo --global

以下のexampleから始める。
https://turborepo.com/docs/getting-started/examples
Next.js(フロント) × Hono(バック) × 共通モジュールとするため、なんとなくkitchen-sinkを選択。

名前はexample-turborepoで、パッケージマネージャーはpnpmを使用。

? Where would you like to create your Turborepo? example-turborepo
? Which package manager do you want to use? pnpm

すると、プロジェクトの作成が始まる。

>>> Creating a new Turborepo with:

Application packages
 - apps/admin
 - apps/api
 - apps/blog
 - apps/storefront
Library packages
 - packages/config-eslint
 - packages/config-typescript
 - packages/jest-presets
 - packages/logger
 - packages/ui

>>> Success! Created your Turborepo at example-turborepo

To get started:
- Change to the directory: cd example-turborepo
- Enable Remote Caching (recommended): pnpm dlx turbo login
   - Learn more: https://turborepo.com/remote-cache

- Run commands with Turborepo:
   - pnpm run build: Build all apps and packages
   - pnpm run dev: Develop all apps and packages
   - pnpm run lint: Lint all apps and packages
   - pnpm run test: Test all apps and packages
   - Run a command twice to hit cache

次のページは既存リポジトリへの追加だった。ここからは別のドキュメントを参考にしながら進めていく。

miyamyiamiyamyia

admin/[...]/main.tsxの内容がこれだった。
ぱっと見フロントからバックへのアクセスはしてなさそうかも。

import "./styles.css";
import { CounterButton } from "@repo/ui/counter-button";
import { Link } from "@repo/ui/link";

function App() {
  return (
    <div className="container">
      <h1 className="title">
        Admin <br />
        <span>Kitchen Sink</span>
      </h1>
      <CounterButton />
      <p className="description">
        Built With{" "}
        <Link href="https://turborepo.com" newTab>
          Turborepo
        </Link>
        {" & "}
        <Link href="https://vitejs.dev/" newTab>
          Vite
        </Link>
      </p>
    </div>
  );
}

export default App;

でも、一旦このリポジトリの内容を解析してみる。

miyamyiamiyamyia

ルートのturbo.jsonを見ていく。

$schema

"$schema": "https://turborepo.com/schema.json"

turbo.jsonのスキーマを指定するためのものらしい。tasksを削除したら警告が出た。
https://turborepo.com/docs/getting-started/editor-integration

task

dependsOnから見ていく。
https://turborepo.com/docs/reference/configuration#dependson

dependsOnでは、タスク実行前に完了している必要のあるタスクを指定する。
対象のパッケージが他のパッケージに依存している場合、どのような順序でタスクを実行していくべきかを指定する。
そして、dependsOnには依存関係同一パッケージ関係任意のタスク関係の3種類があ離、^#で表現する。

依存関係

依存している他のパッケージの指定したタスクが完了するまで実行を待つ。turboは、内部依存関係のないパッケージが見つかるまで再起的にパッケージを探す。
以下のような依存関係がある場合。

web -> ui -> utils

webuiに依存しているため、uiのビルドを待つ。uiutilsに依存しているため、utilsのビルドを待つ。utilsは他に依存関係がないため、ビルドが実行される。ビルドが完了次第、次のビルドが実行される。

これを表現するのが^であり、以下のコードは「ビルド時には依存関係のビルドが完了するまで実行を待つ」ことを表現している。

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"]
    }
  }
}

同一パッケージ関係

同一パッケージの他タスクが完了するのを待つ。^を使用せずに記述することで同一パッケージ関係を表現する。以下のコードは、「テスト時には同一パッケージのリントとビルドが完了するまで実行を待つ」ことを表現している。

{
  "tasks": {
    "test": {
      "dependsOn": ["lint", "build"]
    }
  }
}

任意のタスク関係

依存しているすべてのパッケージや、自分自身の他タスクではなく、特定のパッケージのタスクの完了をまつ。依存パッケージ#タスク名で表現する。
以下のコードは、「webのリント時にはutilsのビルドが完了するまで実行を待つ」ことを表現している。

{
  "tasks": {
    "web#lint": {
      "dependsOn": ["utils#build"]
    }
  }
}
miyamyiamiyamyia

inputs

パッケージが変更されたかどうかを判断するためのファイルを指定する。

何も指定していなければソース管理にチェックインされているファイルを参照する。(.gitignoreに含まれていないファイル)

inputsを指定し、変更を判断するファイルを自分で記述した場合、上記のデフォルト動作が機能しなくなり、指定したファイルのみで変更を判断するようになる。ただし、turbo.jsonの変更は必ず検知される。
デフォルトの動作を保ったまま対象ファイルを追加したり特定のファイルのみは参照しないようにするには、$TURBO_DEFAULT$を使用する。

以下のコードは、「デフォルトの動作はそのままに、README.mdの変更は検知しない」ことを表現している。

{
  "tasks": {
    "check-types": {
      "inputs": ["$TURBO_DEFAULT$", "!README.md"]
    }
  }
}

またパッケージ内ではなく、リポジトリのルートディレクトリから参照したい場合には、$TURBO_ROOT$を使用する。

以下のコードは、「ルートディレクトリに存在するtsconfig.jsonと、パッケージ内のsrc以下のtsファイルの変更を検知する」ことを表現している。

{
  "tasks": {
    "check-types": {
      // Consider all Typescript files in `src/` and the root tsconfig.json as inputs
      "inputs": ["$TURBO_ROOT$/tsconfig.json", "src/**/*.ts"]
    }
  }
}
miyamyiamiyamyia

outputs

タスクが正常終了した時にキャッシュされる成果物を指定する。
inputsで指定したファイルに変更がないときには、outputsの中身をそのまま成果物として扱う。変更されたパッケージのタスクのみを再実行し、無駄なタスクの再実行を防ぐことができる。

以下のコードは、「dist以下の成果物はinputsに変更がない限り前回の結果が使用される」ことを表現している。

{
  "tasks": {
    "build": {
      "outputs": ["dist/**"]
    }
  }
}
miyamyiamiyamyia

cacheとpersistent

devなどはキャッシュされてはならない(キャッシュヒットしたら開発サーバーが建たないは流石に…)
そのため、cachefalseに設定する。また、persistenttrueにすることで、他のタスクから依存されないようにする。(設定ミスでtestbuildなどがdevに依存してしまい、タスクが完了しないという事態を避けるため)

miyamyiamiyamyia

ここまで読んでいくと、サンプルのturbo.jsonを見てもわかりそう。(uiは忘れてたけど、tuiでは各ログを一度に表示し、操作可能。streamではすぐに出力され、対話型ではない。)

{
  "$schema": "https://turborepo.com/schema.json",
  "ui": "tui",
  "tasks": {
    "build": {
      "inputs": ["$TURBO_DEFAULT$", ".env*"],
      "dependsOn": ["^build"],
      "outputs": [
        "build/**",
        ".vercel/**",
        "dist/**",
        ".next/**",
        "!.next/cache/**"
      ]
    },
    "test": {
      "outputs": ["coverage/**"],
      "dependsOn": []
    },
    "lint": {
      "dependsOn": ["^build", "^lint"]
    },
    "check-types": {
      "dependsOn": ["^build", "^check-types"]
    },
    "dev": {
      "dependsOn": ["^build"],
      "cache": false,
      "persistent": true
    }
  }
}

build

  1. .gitignoreに追加されていないファイルと.env*ファイルが変更されている場合、ビルドを実行する。
  2. 自分が依存しているパッケージのビルドが完了するまで待機する。
  3. 以下のフォルダをキャッシュする
    1. build
    2. vercel
    3. dist
    4. .next
  4. 以下のフォルダをキャッシュの対象から外す。
    1. .next/cache

test

  1. .gitignoreに追加されていないファイルが変更されている場合、テストを実行する。
  2. 依存関係はなく、テストカバレッジ(coverage/**)をキャッシュする。

lint

  1. .gitignoreに追加されていないファイルが変更されている場合、リントを実行する。
  2. 自分が依存しているパッケージのビルドとリントが完了するまで待機する。

check-types

  1. .gitignoreに追加されていないファイルが変更されている場合、型チェック(tsc --noEmit)を実行する。
  2. 自分が依存しているパッケージのビルドと型チェックが完了するまで待機する。

dev

  1. .gitignoreに追加されていないファイルが変更されている場合、型チェック(tsc --noEmit)を実行する。
  2. 自分が依存しているパッケージのビルドが完了するまで待機する。
  3. キャッシュを無効化し、前回から変更がなくとも開発サーバーが立ち上がるようにする。
  4. 他のタスクから依存されないようにする。
miyamyiamiyamyia

Next.js × Honoを構築してみる

Honoのrpcを使用したいため、依存の向きとしてはこのようになる。

next -> hono

以下に従って最初からプロジェクトを作成する。
https://turborepo.com/docs/crafting-your-repository/structuring-a-repository

その後、appsの中にnext/hono/としてプロジェクトを作成する。Next.jsはOpenNextを使用する。
https://opennext.js.org/cloudflare/get-started
https://hono.dev/docs/getting-started/cloudflare-workers

miyamyiamiyamyia

簡単にコードを書いてみた。

  1. APIエンドポイント
    const app = new Hono()
       .get('/', (c) => {
         return c.text('Hello Hono!')
       })
     
     export default app
     
     export type AppType = typeof app
    
    といってもほとんど初期のままで、app.getnew Hono().getにすることで、型情報が失われることを防ぐ程度。あとは、AppTypeをexportすることで、Honoのエンドポイントを型安全に呼び出す準備を行う。
  2. Hono Client
    export const getClient = (url: string = "http://localhost:53925") => {
     return hc<AppType>(url)
    }
    
    フロントエンドの環境変数によってAPIのエンドポイントを変更したいため、urlを受け取りクライアントを返す関数を作成。フロント側にHonoを入れたくないため、この関数はHono側に実装。
  3. Next.js
    'use client'
    
    import { getClient } from "@hono/client"
    import { useEffect, useState } from "react"
     
    export default function Home() {
      const [message, setMessage] = useState("")
      const client = getClient(process.env.NEXT_PUBLIC_API_URL)
      useEffect(() => {
         (async () => {
           const message = await (await client.index.$get()).text()
           setMessage(message)
         })()
     
       }, [client])
     
       return (
         <p>
           {message}
         </p>
       )
     }
    
    Honoのクライアントを使用してリクエストを送信するだけの簡単なコード。
  4. tsconfig
    "@hono/*": ["../hono/src/*"]
    
    Honoのクライアントなどを@honoで利用できるようにtsconfigを修正。
miyamyiamiyamyia

【ちょっとした気づき集】

  1. OpenNextでデプロイしているNext.jsアプリのクライアントサイドで環境変数にアクセスするにはデプロイ時に.envファイルにNEXT_PUBLICプレフィクスをつけた環境変数を書いておく必要があるらしい。
    .envと.dev.vars

    ここはNext.jsの仕様なのかな?どうも.env.dev.varsあたりがよくわからない。サーバーサイドで使いたいものは.dev.varsに書いて、クライアントで使いたいものはNEXT_PUBLIC付きで.envにおけばいいのかな?ただ、ダッシュボードでクライアントサイドでどんな環境変数を使ってるのかがわからないのが難点。そう考えるといっそ.envもNEXT_PUBLIC前提でリポジトリに含めちゃってもいいのかな

  2. HonoのCORS設定では関数を渡すことができる。そのため、自動生成されるURLがCORSで弾かれないようにするためにはこんな感じの設定ができる。
    .use("*", cors({
        origin: origin => {
          if(origin.includes('localhost')) return origin
          if(origin.endsWith("-app.your-subdomain.workers.dev")) return origin
          return null
        }
    }))
    
miyamyiamiyamyia

これ、先頭に-つけるとworkers dev URLはCORSで弾かれるから消した方がいいかも。

miyamyiamiyamyia

とりあえずごく簡単に。
ルートのpackage.jsonturbo.jsonにデプロイ関係の記述を追加。

package.json
"deploy": "turbo run deploy",
turbo.json
"deploy": {
      "dependsOn": ["^deploy"]
}

これでキャッシュが効くことも確認した。パフォーマンスは一旦置いておくにしても、もうこれで動かせるのか??
次はGitHub Actionsを使ったCI/CDの構築にチャレンジかな。

miyamyiamiyamyia

こんな感じにしてみた。Cloudflareへのデプロイではcloudflare/wrangler-actionが使われがちだが、今回はturborepoの機能を使ってキャッシュを活かしたかったため、pnpm run deployでデプロイするようにした。今まで知らなかったけど、環境変数でCLOUDFLARE_API_TOKENを渡しておけばwrangler deployでもしっかりデプロイできるみたい。

ci.yaml
name: Deploy
 
on:
  push:
    branches-ignore: ["main"]
  pull_request:
    types: [opened, synchronize]
 
jobs:
  deploy:
    name: Deploy
    timeout-minutes: 15
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [22]
    env:
     TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
     TURBO_TEAM: ${{ vars.TURBO_TEAM }}
     CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
 
    steps:
      - name: Check out code
        uses: actions/checkout@v4
        with:
          fetch-depth: 2
 
      - name: Install pnpm
        uses: pnpm/action-setup@v3
        with:
          version: 10
 
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'pnpm'
 
      - name: Install dependencies
        run: pnpm install
 
      - name: Lint
        run: pnpm lint

      - name: Deploy
        run: pnpm run deploy
        env: 
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

この時には、turbo.jsonenvを指定する必要がある。

turbo.json
"deploy": {
  "dependsOn": [
    "^deploy"
  ],
  "env": [
    "CLOUDFLARE_API_TOKEN"
  ]
}
miyamyiamiyamyia

GitHub Actionsを使ってデプロイする時には、.envファイルがないからNEXT_PUBLIC_API_URLが存在しないんだ。
NEXT_PUBLIC系はビルド時に必要だから、envで渡してあげる必要がある。

- name: Deploy
run: pnpm run deploy
env: 
  CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
  NEXT_PUBLIC_API_URL: ${{ vars.NEXT_PUBLIC_API_URL }}
このスクラップは3ヶ月前にクローズされました