Node.jsサーバーからReact, Vue appをサーブする

最近は多くのサービスでビルドしたWebアプリケーションがvercelやaws-amplifyのサービスによって簡単にデプロイさせることができます。

しかし、例えばNode.js サーバーでWebAPIを構築してその検証や内部利用のみの簡単なWebアプリケーションを同じサーバーから配信したかったりすることがあるのではないかと思います。

そこで忘備録としてSPAをNode.jsサーバーから配信するためにどのような設定が必要なのか見ていきたいと思います。

今回はHonoを用いたシンプルなサーバーと、viteでビルドするweb アプリケーションを例に見ていきたいと思います。こな気はreactですが、vueでもsvelteでも同様です。

# ただビルドをサーブしてみる

まずはうまくいかないケースとその原因を見ていきたいと思います。
ReactでビルドしたアプリをHonoからサーブしてみます

```ts:vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  build: {
    outDir: '../../dist/react-app'
  },
  plugins: [react()],
})
```
`vite.config.ts`ではデフォルトの設定に対してbuildのoutDirを変更しています。
今回はVueとReactの二つのビルドがあるためビルドの保存先を分ける必要があるからです。

```ts:server.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { html } from 'hono/html'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = new Hono()

app.get('/react-app', async (c) => {
  const file = path.join(__dirname, '../dist/react-app/index.html')
  const content = await fs.promises
    .readFile(file, 'utf-8')
  c.header('Content-Type', 'text/html')
  return c.html(content)
})

serve({
  fetch: app.fetch,
  port: 3000
}, (info) => {
  console.log(`Server is running on http://localhost:${info.port}`)
})
```

Honoではreactのアプリケーションを `/reacct-app` のパスへのリクエストで返すようにしました。
ビルドされたhtmlファイルを取得し返します。

### 結果

ブランクな画面のままで何も表示されません。

![スクリーンショット 2025-03-02 8.55.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/336928/0b3d3133-a316-4d25-9a8b-734aff13bb47.png)

エラーを見てみるとサーバーで設定していないパスにjsやcssファイルがリクエストされており404 not foundエラーとなっています。

## Viteのビルドを見直す

viteビルドしたアプリケーションのHTMLがHonoのサーバーのパスと合致していないのが原因です

```html:dist/react-app/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
    <script type="module" crossorigin src="/assets/index-kT8-kBJj.js"></script> <!-- unmatching path -->
    <link rel="stylesheet" crossorigin href="/assets/index-D8b4DHJx.css">
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

```

`vite.config.ts`でアプリケーションのベースとなるURLを変更しましょう

```ts:vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  base: '/react-app/', // base url has changed to match with the hono server
  build: {
    outDir: '../../dist/react-app'
  },
  plugins: [react()],
})

```

### 結果2

まだブランクなままで表示されません。

![スクリーンショット 2025-03-02 9.12.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/336928/675d25db-8e0f-4285-9af9-8c05aaa18e0f.png)

jsやcssのパスは `/react-app...`で合致していますが、今度はHono側で `/react-app`ルートのベースURLにしかサーバーのパスを設定していないため404エラーとなってしまっています。

## Honoで静的アセットへのリクエストをハンドリングする

簡易な対応にはなりますが、`/react-app`以降のリクエストを静的に返すようにHonoにミドルウェアを追加します

```ts:server.ts
import { serve } from '@hono/node-server'
import { serveStatic } from 'hono/serve-static' // added
import { Hono } from 'hono'
import { html } from 'hono/html'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = new Hono()

app.get('/react-app', async (c) => {
  const file = path.join(__dirname, '../dist/react-app/index.html')
  const content = await fs.promises
    .readFile(file, 'utf-8')
  c.header('Content-Type', 'text/html')
  return c.html(content)
})

app.use('/react-app/*', serveStatic({ // added
  root: '../dist',
  getContent: async (filePath, c) => {
    try {
      const content = await fs.promises.readFile(path.join(__dirname, filePath), 'utf-8');
      const ext = path.extname(filePath);
      let contentType = 'text/plain';

      if (ext === '.js') {
        contentType = 'application/javascript';
      } else if (ext === '.css') {
        contentType = 'text/css';
      } else if (ext === '.html') {
        contentType = 'text/html';
      } else if (ext === '.svg') {
        contentType = 'image/svg+xml';
      } else {
        console.log('Unknown file type:', ext);
        contentType = 'text/plain';
      }

      c.header('Content-Type', contentType);
      return new Response(content);
    } catch (e) {
      console.error(e);
      return null;
    }
  }
}))

serve({
  fetch: app.fetch,
  port: 3000
}, (info) => {
  console.log(`Server is running on http://localhost:${info.port}`)
})
```

### 結果3

![スクリーンショット 2025-03-02 12.12.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/336928/1975665d-2983-453f-a99e-9b940d18e7a0.png)

無事jsやcssをブラウザが取得でき、アプリが描画されました。

#### routingのあるSPAの場合

react-routerやvue-routerを利用している場合はすべてのパス(eg; /react-app/foo)に対してindex.htmlを返す必要があります。

ブラウザのURLが/react-app/fooの場合はリクエストが/react-app/foo.htmlになるためそのようなファイルは存在せず404エラーとなります。

そのため
```ts
if (path.extname(filePath) === '.html') {
    filePath = '../dist/react-app/index.html';
  }
```

のように読み込むパスを変更するなどの工夫が必要ですね