生成AI時代のためのVite+Expressアプリ構成を考えてみた

# 概要
以前、生成AIを用いたアプリ開発ではNext.jsよりもViteの方が使いやすいと述べました。
参考：[Claude 3.5 Sonnet Artifactsで爆速アプリ開発してみた](https://qiita.com/MokonaSato/items/de15130792f403df63ba)
ですが、サーバサイド処理はNext.jsの方がお手軽にできるイメージでした。
そこで、今回は自分用のメモとして、Viteを用いたサーバサイド処理の実装方法をまとめます。
例によって、Claude 3.5に実装方法のベースを書かせています。

# 環境
- マシン：Macbook Air (M1チップ、2020)
- OS:Sonoma 14.6
- Docker:v4.33.0
- Node.js v18.16.0
- MySQL Ver 8.0.36 for macos14.2 on arm64 (Homebrew)

# アーキテクチャ
- Viteプロジェクトの中でExpressサーバを立てて、データフェッチはサーバサイド、レンダリングはクライアントサイドで行います。
- Server-side処理とClient-side処理が分かりやすいよう、docker composeを用います。
- Expressサーバはコンテナ外からのアクセスは出来ないようにします。

![アーキテクチャ改.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118714/bc26ba95-bc56-d916-5500-6005af61333c.png)

アプリケーションを公開するならGoogle Cloud Platformのコンテナ系サービスを使いたい気持ちで書いています。

# 実装方法
1. 新しいディレクトリを作成し、Viteプロジェクト用ディレクトリに移動します
    ```bash
    mkdir my-app
    cd my-app
    mkdir vite-project database
    cd vite-project
    ```
1. Viteプロジェクトを作成します
    ```bash
    npm create vite@latest
    npm install
    ```
1. 必要なパッケージをインストールします
    ```bash
    npm install express mysql2
    npm install -D concurrently ts-node @types/express @types/node
    ```
1. src/server.tsファイルを作成し、以下の内容を追加します。
    ```react:src/server.ts
    import express from 'express';
    import mysql from 'mysql2/promise';
    import cors from 'cors';
    
    const app = express();
    const port = 3000;
    
    app.use(cors());
    
    app.get('/api/hello', async (req, res) => {
      try {
        const connection = await mysql.createConnection({
          host: process.env.DB_HOST || 'database',
          user: process.env.DB_USER || 'root',
          password: process.env.DB_PASSWORD || 'password',
          database: process.env.DB_NAME || 'testdb',
          port: 3306
        });
    
        const [rows] = await connection.execute('SELECT * FROM hello_world');
        await connection.end();
    
        if (Array.isArray(rows) && rows.length > 0) {
          res.json(rows[0]);
        } else {
          res.status(404).json({ error: 'No data found' });
        }
      } catch (error) {
        console.error('Database error:', error);
        res.status(500).json({ error: 'Internal Server Error', details: error instanceof Error ? error.message : String(error) });
      }
    });
    
    app.listen(port, '127.0.0.1', () => {
      console.log(`Server running at http://localhost:${port}`);
    });
    ```
    - Expressサーバがコンテナ内でのみアクセス可能になるように、app.listenに`127.0.0.1`(=localhost)を指定します。
1. src/App.tsxファイルを次のように更新します。
    ```react:src/App.tsx
    import { useState, useEffect } from 'react';
    
    function App() {
      const [message, setMessage] = useState('');
      const [error, setError] = useState('');
    
      useEffect(() => {
        fetch('/api/hello')
          .then(response => {
            if (!response.ok) {
              throw new Error(`HTTP error! status: ${response.status}`);
            }
            return response.json();
          })
          .then(data => {
            if (data.message) {
              setMessage(data.message);
            } else {
              setError('Unexpected data structure');
            }
          })
          .catch(error => {
            console.error('Error:', error);
            setError(`Failed to fetch data: ${error.message}`);
          });
      }, []);
    
      if (error) {
        return <div>Error: {error}</div>;
      }
    
      return (
        <div>
          <h1>Message from MySQL:</h1>
          <h1>{message}</h1>
        </div>
      );
    }
    
    export default App;
    ```
    - APIリクエストをプロキシする設定を行います。
1. package.jsonファイルのscriptフィールドを以下のように更新します。
    ```json:package.json
    "scripts": {
        "dev": "concurrently \"vite\" \"NODE_OPTIONS='--experimental-specifier-resolution=node' node --loader ts-node/esm src/server.ts\"",
        "build": "tsc && vite build",
        "preview": "vite preview"
      }
    ```
    - concurrentlyを使うことで、フロントエンドの開発サーバーとバックエンドのサーバーを同時に立ち上げる
    - `--experimental-specifier-resolution=node`: Node.jsがESモジュールの解決方法を変更
    - `--loader ts-node/esm`: TypeScriptのコードを直接実行するためのローダー。ts-nodeはTypeScriptのコードを直接実行できるツールで、`/esm`はESモジュールをサポートするためのオプション。
1. tsconfig.app.jsonを削除し、tsconfig.jsonを以下のように更新します。
    ```json:tsconfig.json
    {
      "compilerOptions": {
        "target": "ES2020",
        "module": "ESNext",
        "moduleResolution": "node",
        "esModuleInterop": true,
        "strict": true,
        "outDir": "./dist",
        "rootDir": "./src",
        "typeRoots": ["./node_modules/@types"],
        "types": ["node"],
        "allowJs": true,
        "resolveJsonModule": true,
        "allowSyntheticDefaultImports": true,
        "jsx": "react-jsx"
      },
      "ts-node": {
        "esm": true,
        "experimentalSpecifierResolution": "node"
      },
      "include": ["src/**/*"],
      "exclude": ["node_modules"]
    }
    ```
1. vite.config.tsを以下のように更新します。
    ```typescript:vite.config.ts
    import { defineConfig } from 'vite'
    import react from '@vitejs/plugin-react'
    
    // https://vitejs.dev/config/
    export default defineConfig({
      plugins: [react()],
      server: {
        host: '0.0.0.0',
        port: 5173,
        strictPort: true,
        proxy: {
          '/api': 'http://127.0.0.1:3000'
        }
      }
    })
    ```
1. ViteプロジェクトのルートディレクトリにDockerfileを作成し、以下の内容を追加します。
    ```docker:Dockerfile
    FROM node:18
    
    WORKDIR /app
    
    COPY package*.json ./
    
    RUN npm install
    
    COPY . .
    
    EXPOSE 5173
    
    CMD ["npm", "run", "dev"]
    ```
1. databaseディレクトリ配下にinit.sqlファイルを作成し、以下の内容を追加します。
    ```sql:init.sql
    USE testdb;
    CREATE TABLE IF NOT EXISTS hello_world (
      id INT AUTO_INCREMENT PRIMARY KEY,
      message VARCHAR(255) NOT NULL
    );
    INSERT INTO hello_world (message) VALUES ('Hello World');
    ```
1. my-appディレクトリ配下にdocker-compose.yamlを作成し、以下の内容を追加します。
    ```yaml:docker-compose.yaml
    version: '3'
    services:
      app:
        build: ./vite-project
        ports:
          - "5173:5173"
        environment:
          - DB_HOST=database
          - DB_USER=root
          - DB_PASSWORD=mysql
          - DB_NAME=testdb
        depends_on:
          - database
      database:
        image: mysql:8
        environment:
          MYSQL_ROOT_PASSWORD: mysql
          MYSQL_DATABASE: testdb
        volumes:
          - ./mysql-data:/var/lib/mysql
          - ./database/init.sql:/docker-entrypoint-initdb.d/init.sql
    ```
1. vite-projectディレクトリからmy-appディレクトリに移動し、コンテナを立ち上げます。
    ```bash
    cd ..
    docker compose up -d
    ```
1. ブラウザを立ち上げ、`http://localhost:5173`に接続して、以下の画面が表示されていることを確認します。
    ![Vite.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118714/cff804fb-fefb-008d-4f9d-907678db7ece.png)
1. ブラウザから、`http://localhost:3000/api/hello`に接続して、アクセスできないことを確認します。
    ![Express.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118714/c82d2a62-8dbe-8fad-c6d3-5a900cb769ce.png)