モノレポでVueのSPA × HonoのAPI の技術スタックでCloudflare PagesにDeployし、フルスタックな開発する

## はじめに

Cloudflare Pagesでは静的なコンテンツは無制限に配信できる（すごいですよね）。また、Cloudflare Pages Functionsを利用すればエッジ上でJavaScriptを実行でき、バックエンドのサーバーを用意することもできる。

今回はそんなCloudflareのスタックに乗っかり、簡単にフロントエンドはSPA（Vue.js）で実装して静的なコンテンツとして配信、バックエンドはHonoでAPIを実装、というのをやってみたいと思う。

要件は以下。

- `/api/*`以外はSPA（静的なコンテンツの配信）として処理されるようにし、`/api/*`のみPages Functionsで処理する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/6e34a4aa-ee3e-f076-fc9d-9dc39b699276.png)

※[HonoでAPI付き雑React SPA最小](https://zenn.dev/yusukebe/articles/06d9cc1714bfb7)、[HonoでAPIだけ作って素のReact DOMでSPAを書くアーキテクチャ](https://zenn.dev/laiso/articles/c7eba95ce43feb)など、似たようなことを扱っている記事はあるが、それらはSPAの配信の部分をHonoの中で行う実装になっている。つまり、PagesでSPAを配信するのではなく、Pages Functionsで処理してSPAを返すようになっている。Pages Functionsは無料枠では処理できるリクエストの上限があるし、課金でもリクエスト回数で課金されるので、SPAはPages Functionsにリクエストが届かずとも配信できるようにしたいと考えた。今回やってみることは、Honoは完全にAPIの処理のみを行い、SPAはPagesで配信するという事になる。

## SPAの設定

まずは、[create-vue](https://github.com/vuejs/create-vue)で一からプロジェクトを作成する。私はVuetifyまで入れたので最終的には、`vite.config.js`は以下のようになった。

```js:vite.config.js
import { fileURLToPath, URL } from 'node:url';

import { defineConfig } from 'vite';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
import vue from '@vitejs/plugin-vue';
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify';
import Components from 'unplugin-vue-components/vite';
import vueDevTools from 'vite-plugin-vue-devtools';
import eslint from 'vite-plugin-eslint';

export default defineConfig(() => {
	return {
		plugins: [
			nodePolyfills({ protocolImports: true }),
			vue({ template: { transformAssetUrls } }),
			vuetify({
				autoImport: true,
				styles: { configFile: 'src/styles/settings.scss' }
			}),
			Components(),
			vueDevTools(),
			eslint()
		],
		resolve: {
			alias: {
				'@': fileURLToPath(new URL('./src', import.meta.url))
			}
		},
		server: {
			watch: 'src/**',
			host: '0.0.0.0',
			port: 8080
		}
	};
});
```

フォルダ構成はそれこそ普通のVueのプロジェクトの通りになる（開発しているアプリに関連するSFCの名前になっている）。

```console
$ tree src/
src/
├── App.vue
├── assets
│   └── logo.svg
├── main.js
├── plugins
│   ├── index.js
│   └── vuetify.js
├── router
│   └── index.js
├── stores
│   ├── problem.js
│   └── setting.js
├── styles
│   └── settings.scss
└── views
    ├── HomeView.vue
    ├── ProblemDetailView.vue
    └── ProblemsView.vue
```

SPAの方はこれで特におしまい。
続いてHonoの設定を行っていく。

## HonoでAPIを実装する

Cloudflare PagesはNode.jがランタイムではないので、ブラウザ環境と同じようにviteでビルドを行う必要がある。そこでviteの設定が必要になる。
Honoも初期プロジェクトを作成できるCLIツールがあり、それを利用すれば簡単に設定できるが、今回はSPAのプロジェクトに同居させるので、それは利用しない。`vite.config.js`を以下のように書き換える。

```js:vite.config.js
import { fileURLToPath, URL } from 'node:url';

import { defineConfig } from 'vite';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
import vue from '@vitejs/plugin-vue';
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify';
import Components from 'unplugin-vue-components/vite';
import vueDevTools from 'vite-plugin-vue-devtools';
import eslint from 'vite-plugin-eslint';

// for hono
import build from '@hono/vite-cloudflare-pages';
import devServer from '@hono/vite-dev-server';
import adapter from '@hono/vite-dev-server/cloudflare';

// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
	// for hono
	if (mode === 'server')
		return {
			plugins: [
				nodePolyfills({ protocolImports: true, globals: { Buffer: true } }),
				build({ entry: 'srv/index.js' }),
				devServer({
					adapter,
					entry: 'srv/index.js'
				})
			],
			// Configuration for avoiding the following errors
			/**
			 * Error: The following dependencies are imported but could not be resolved:
			 * @/plugins (imported by /home/study/src/main.js)
			 * Are they installed?
			 */
			resolve: {
				alias: {
					'@': fileURLToPath(new URL('./src', import.meta.url))
				}
			},
			server: { host: '0.0.0.0', port: 3000, watch: 'srv/**' }
		};

	// for vue
	return {
		plugins: [
			nodePolyfills({ protocolImports: true }),
			vue({ template: { transformAssetUrls } }),
			vuetify({
				autoImport: true,
				styles: { configFile: 'src/styles/settings.scss' }
			}),
			Components(),
			vueDevTools(),
			eslint()
		],
		resolve: {
			alias: {
				'@': fileURLToPath(new URL('./src', import.meta.url))
			}
		},
		server: {
			proxy: {
				'^/api': {
					target: 'http://192.168.56.5:3000'
				}
			},
			watch: 'src/**',
			host: '0.0.0.0',
			port: 8080
		}
	};
});
```

ポイント以下の部分になる。

- `mode`の利用
  - ここは[HonoでAPI付き雑React SPA最小](https://zenn.dev/yusukebe/articles/06d9cc1714bfb7)、[HonoでAPIだけ作って素のReact DOMでSPAを書くアーキテクチャ](https://zenn.dev/laiso/articles/c7eba95ce43feb)の記事を参考にさせて頂いた
  - Vue（SPA）はVueでビルトをする必要があり、HonoはHonoでビルドをする必要があるが、それをうまく1つのプロジェクト（モノレポ）で実現するために、viteの`mode`を利用してビルドする対象ごとに設定が変わるようにしている
  - Vueのビルドは`$ vite build`で、Honoのビルドは`$ vite build --mode server`になる
- Honoのディレクトリの設定
  - Honoを一からセットアップ（CLIツールで作成）した後のプロジェクトは`src/`以下にHonoのコードが配置されるような構成になるが、Vueの方でそれは使っているので`srv/`以下にしている
  - `srv/`以下がエントリーになるように指定するために、`entry`オプションを設定している
- `resolve.alias`の設定をHonoの設定にも追加する
  - これはaliasを利用している場合のみに関係してくるが、Vueの方でこの設定があるとviteの仕組み上エラーになるため、Honoの方でもこの設定が必要

 このように設定することで、開発時は以下のようなコマンドでそれぞれのdevServerを起動でき、ビルドもそれぞれできるようになる。

 ```json:package.json
	"scripts": {
		"dev": "vite",
		"dev:server": "vite --mode server",
		"build": "vite build && yarn build:server && yarn replace:routes",
		"build:server": "vite build --mode server"
	},
 ```

 Honoのディレクトリ構成は以下のようになる。

 ```console
$ tree ./srv
./srv
├── index.js
└── lib
    ├── http-error.js
    └── jwt.js
 ```

 最後に、Cloudflare PagesにDeployするための設定についてみていく。

 ## Cloudflare PagesにDeployするための設定

 上記で開発とビルドまでできるようになったが、今回の要件として、VueはPagesの静的コンテンツの配信にし、APIはPages Functionsにする、というのがある。そのための設定を行っていく。

 まず、Cloudflare PagesにDeployするときのフォルダ構成・ファイルのルールを理解する必要があるので、それについてみていく。

 まず、基本的にCloudflare Pages Functionsは[Create a Function](https://developers.cloudflare.com/pages/functions/get-started/#create-a-function)に書かれている通り、`/functions`というフォルダ以下にファイルを配置すると、それがPages FunctionsとしてDeployされるような仕組みになっている。つまり、Pagesの設定でビルド後の生成物のディレクトリを`./dist`としたなら、以下のようなフォルダ構成にすると、自動的にfuncions以下をPages Functionsと認識してくれるという事。

 ```console
$ tree ./dist/
./dist/
├── assets
│   ├── index-BXVXHimq.js
│   └── index-D8XTPsNk.css
├── favicon.ico
├── functions
│   ├── helloworld.js
│   └── index.js
└── index.html
 ```

 ただ、HonoをはじめとしたCloudflare Pages Functions向けのビルドを行うもの（アダプター）は、このフォルダ構成でPages Functionsを作成できる方法を利用しない。代わりに、`_worker.js`を生成して`_routes.json`によるルーティング設定を行う（[Advanced mode](https://developers.cloudflare.com/pages/functions/advanced-mode/)、[Create a _routes.json file](https://developers.cloudflare.com/pages/functions/routing/#create-a-_routesjson-file)を参照）。

実際にHonoをビルドすると、以下のように`_worker.js`と`_routes.json`が生成される。

 ```console
$ tree ./dist/
./dist/
├── _routes.json
├── _worker.js
├── assets
│   ├── index-BXVXHimq.js
│   └── index-D8XTPsNk.css
├── favicon.ico
└── index.html
 ```

 ただ、このままだと今回の要件は満たせない。理由は自動的に生成される`_routes.json`が以下のようになっており、すべてのリクエストをPages Functionsに振り向けてしまうから。

 ```json:_routes.json
{ "version": 1, "include": ["/*"], "exclude": ["/assets/*", "/favicon.ico", "/index.html"] }
 ```

これを要件に合うように変更する必要があるが、 `include`の部分がポイントで、`"/*"`の状態だとすべてのリクエストがPages Functionsに振り向けられるため、ここを`"/api/*"`に上書きしてしまえば、`/api/*`のパスのみPages Functionsに振り向けられるので、それ以外のリクエストをすべて静的なコンテンツの配信として処理できるようになる。

```json:_routes.json
{"version":1,"include":["/api/*"],"exclude":["/assets/*","/favicon.ico","/index.html"]}
```

これをビルドのプロセスでどうやるか？だが、以下のようにビルドコマンドを定義してしまえばできる。単純にシェルスクリプトで置き換える。この方法であれば、Cloudflare PagesのDeploy時の設定で`yarn build`を指定すれば、`_routes.json`の編集までできるので、今回やりたかったことが実現できる。

```json:package.json
	"scripts": {
		"build": "vite build && yarn build:server && yarn replace:routes",
		"build:server": "vite build --mode server",
		"replace:routes": "echo '{\"version\":1,\"include\":[\"/api/*\"],\"exclude\":[\"/assets/*\",\"/favicon.ico\",\"/index.html\"]}' > dist/_routes.json",
	},
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/11cedb94-4bd5-36f6-5c76-2f5b82c9cc6b.png)

## まとめとして

今回はVue × HonoをCloudflare PagesにDeployして、フルスタックな開発をできるようにする方法を見てきた。今どきはSSRが多いと思うが、要件によってはSPAの方があうものもあったりすると思う。そんなSPAでバックエンドを用意したくなったときに、Firebaseなどでは物足りず、APIを実装したくなったときにHonoを利用してAPIだけ開発し、それを簡単にDeployできる技術スタックは有用な気もした。

どなたかの役に立てば幸いです。