FastAPI + React SPAのコンフォータブルな開発テンプレートを作る

# 序

https://qiita.com/kedama-t/items/a05e0a8499e3d5cdae84

この記事を書いた半年ほど前(2024/6)の時点では、「_検証用のちょっとしたWebアプリを`Vite`と`React`の組み合わせで作ることが多_」かったのですが、最近は猫も杓子もAIであり、そうすると検証用のアプリといえばそれも大体AIであり、となれば必然的にバックエンドをPythonにしたくなるわけです。[^1]

[^1]:別にPythonが好きなわけではない

そういうわけで、いろいろ開発環境を整備したり模索したりしていたのですが、ある程度「快適」と言えるところまで来たので、記事にしました。

本格的で大規模な開発用ではなく、ちょっとしたツールや小規模なWebアプリの構築を目的としているので、そういうのを求めている人には先にごめんなさいしておきます。

:::note warn
2024年11月時点の情報です。プラットフォームは問わないと思いますが、私が個人的にLinux、Windows環境で試した限りにおいて問題なく動作している、ということに留意ください。
:::

## 最終的なツールスタック
こうなります。

### 開発用ツール
- [uv](https://docs.astral.sh/uv/) - Rust製のPythonバージョン管理・パッケージマネージャ
- [Bun](https://bun.sh/) - 高速なJavaScriptランタイム兼パッケージマネージャ
- [Task](https://taskfile.dev/) - Go製のタスクランナー
    - uvにタスクランナーがついたらこれはなくてもいいかも

### バックエンド
- [FastAPI](https://fastapi.tiangolo.com/ja/) - [Starlette](https://www.starlette.io/)と[Pydantic](https://docs.pydantic.dev/latest/)をベースとしたPython製Webアプリケーションフレームワーク
    - [Flask](https://flask.palletsprojects.com/en/stable/)でもいいと思います（未検証）
    - 個人的にはFastAPIのほうが"薄い"感じで好みです
- [Uvicorn](https://www.uvicorn.org/) - Python用ASGI Webサーバー

### フロントエンド
静的サイトにビルドできるならフレームワークは別になんでもいいと思いますが、開発の快適さから言うと[Vite](https://ja.vite.dev/)が使えるとよいです。今回はなんだかんだで結構手に馴染んできた[Remix v2](https://remix.run/)の後継である[React Routerのv7(プレリリース)](https://reactrouter.com/en/main) を使っています。
- [React Router (v7)](https://reactrouter.com/en/main) - フルスタック(になった)Webアプリケーションフレームワーク
    - ~~Routerとは~~
    - 2024年11月現在、まだプレリリースなので、気になる場合は[Remix v2](https://remix.run/)のSPAモードでもよいです。ほとんどいっしょ
    - 今回はSSR無しで使います

### ビルダー(optional)
- [Nuitka](https://nuitka.net/) - Pythonスクリプトをスタンドアロンアプリにビルドするツール
    - 身近な人に試してもらうとき用
    - 非開発者のPCにPythonは入っていないことが多い[^2]
        - 先の記事にも書きましたが、いちいちWebアプリとしてホストするの面倒で…
    - 先に有名どころで[PyInstaller](https://pyinstaller.org/en/stable/)を試したのですが、exeの起動にえらい時間がかかったのでこちらにしてみました
        - NuitkaはNuitkaで、ビルドにかなり時間かかるという問題があります…痛し痒しです

あとはお好みで[black](https://github.com/psf/black)や[Biome](https://biomejs.dev/ja/)あたりのLinter/Formatterを入れましょう。

[^2]:それはそう

## 環境構築

### [`uv`](https://docs.astral.sh/uv/getting-started/installation/)と[`Bun`](https://bun.sh/docs/installation)のインストール
この2つが起点になります。公式のインストール手順に沿ってインストール。

```sh:Linuxでの例
# uv
curl -LsSf https://astral.sh/uv/install.sh | sh
# Bun
curl -fsSL https://bun.sh/install | bash
```

### プロジェクトフォルダを作る
```sh
# `template-python-spa`の部分は任意
uv init template-python-spa
cd template-python-spa
```

### Pythonのインストール
```sh
# バージョン指定する場合は`uv python install 3.12`などにする
uv python install
```

### 各種Python系フレームワーク・ツールのインストール
`pip install xxx`ではなく`uv add xxx`でインストールします
```sh
# FastAPI
uv add fastapi

# Uvicorn
uv add "uvicorn[standard]"

# Task
# pipのレジストリにあるのがありがたい
uv add --dev go-task-bin

# Nuitka
uv add --dev nuitka
```

### Pythonのエントリーポイントづくり
`uv init`するとディレクトリ内に`hello.py`というファイルができるのですが、削除して新たに`main.py`というファイルを作ります。

```sh
rm hello.py
touch main.py
```

```python:main.py
import os
import sys
from pathlib import Path

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

# 開発用CORS
origins = [
    "http://127.0.0.1:8000",
    "http://localhost:5173",
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/hello")
def read_root():
    return {"Hello": "World"}


def is_nuitka():
    # Nuitkaでコンパイルされているかどうかを確認
    return "__compiled__" in globals()


def get_dir():
    # ディレクトリパスの取得
    if is_nuitka():
        return os.path.dirname(os.path.abspath(sys.argv[0]))
    else:
        return "."


# フロントエンドの配信
frontend_dir = directory = Path(get_dir()) / "frontend/build/client"
# フロントエンドのビルド前にuv run task devしたときにコケないように
if frontend_dir.exists():
    app.mount(
        "/",
        StaticFiles(directory=frontend_dir, html=True),
        name="rr",
    )

if __name__ == "__main__":
    import uvicorn

    if is_nuitka():
        # Nuitkaで実行時はブラウザを開く
        import webbrowser

        webbrowser.open("http://127.0.0.1:8000/", new=2, autoraise=True)

    uvicorn.run(app, host="127.0.0.1", port=8000)
```
- ファイルパスを相対パスで指定してたらうまく動作しなかったので、Nuitkaで実行中は絶対パスを指定するようにしています

### フロントエンド側の準備
React Router公式のテンプレートがあるので、`frontend`フォルダにそれを配置します。
```sh
# v7が正式リリースされたら`@pre`は消えると思います
bunx create-react-router@pre frontend
```

バックエンドはPythonに一任するので、SSRは無効化します。
```diff_typescript:frontend/vite.config.ts
  import { reactRouter } from "@react-router/dev/vite";
  import tsconfigPaths from "vite-tsconfig-paths";
  import { defineConfig } from "vite";
  
  export default defineConfig({
    plugins: [
    reactRouter({
-       ssr: true,
+       ssr: false, // SPAモードにする
      }),
      tsconfigPaths(),
    ],
  });
```

### Vite環境変数
開発環境の時はフロントエンドが`http://localhost:5173`で動くので、APIを叩く処理は開発時のみオリジンを`http://127.0.0.1:8000`へ置き換える必要があります。

```frontend/.env.development
VITE_API_BASE=http://127.0.0.1:8000
```
こんな感じで設定しておき、`fetch`するときに、
```typescript
const response = await fetch(
  `${import.meta.env.VITE_API_BASE}/hello`
);
```
のように呼び出せばよいです。

### `Taskfile.yaml`の準備
タスクを設定していきます
```yaml
version: '3'

tasks:
  run:
    cmds:
      - uv run task build
      - uv run main.py

  build:
    cmds:
      - cd frontend && bun install && bun run build

  dev:
    cmds:
      - uv run uvicorn main:app --reload & (cd frontend && bun install && bun run dev)

  make:
    cmds:
      - uv run task build
      - nuitka ./main.py --standalone --include-data-dir=./frontend/build/client=./frontend/build/client
```
`uv run task xxx`でタスクが実行されます。開発サーバーの起動は`uv run task dev`で、バックエンド・フロントエンドともに、ホットリロードが効きます。
通常のWebアプリとしてホストするときは`uv run task run`、Nuitkaでビルドする場合は`uv run task make`です。

- Nuitkaには`--onefile`という、必要なファイルを一つの`*.exe`に全部入れてくれるオプションがあるのですが、これを使うとどうも一部のマルウェア検知ツールにマルウェア扱いされてしまいます
    - 社内のシステム管理者に逮捕されたくない場合は、やめておくのが無難かと思います（１敗）
- Nuitkaは現在のところ、クロスコンパイルができません
    - Windows用のexeを作りたい場合は、Windows環境でやる必要があります

## 運用

これでテンプレートとしては完成です。GitHubにでも置いておきましょう。
開発端末上に`uv`と`Bun`が必要になるのがネックかもですが、それさえ乗り越えられれば、`git clone`からの`uv sync`ですぐ作り始められます。
- 試してはいないですが、おそらくBunを使ってるところは全部`npm`に置き換えられると思うので、Bunはまだちょっと…という方はNodeに差し替えましょう

# 終わりに
`uv`に早くタスクランナーがついてほしいのと、バックエンドがPythonであるElectronの登場が待たれます。[^3]
[^3]:別にPythonを書きたいわけではない