Cloudflare WorkersでもRemix on Honoをしたい🔥

:::note info
2024年11月現在、yusukebeさんの[hono-remix-adapter](https://github.com/yusukebe/hono-remix-adapter)で同じことができます。（筆者もコントリビュートしています。）
:::


Cloudflare WorkersでRemix on Honoを動かしたときのメモ（2024年6月）

:::note alert
この記事はアーカイブとして残しております。
同じことをやりたい方は[hono-remix-adapter](https://github.com/yusukebe/hono-remix-adapter)をご利用ください。
:::


完成リポジトリは[こちら](https://github.com/ogadra/hono-and-remix-on-vite-at-workers)

https://github.com/ogadra/hono-and-remix-on-vite-at-workers

## 作成動機

- Remix on Honoをやってみたかった
    - Cloudflareを使ってアプリケーションを作りたかった
    - フロントエンドとバックエンドを両方使いたかった
    - バックエンドにおいて、Cloudflare Pagesではしきれないような処理をしたかった
- Pages + Workersのように分けるには冗長なように感じた
    - 個人でサクッと開発したかった

## 執筆動機

Remix and Hono on Viteはyusukebeさんが作成してくださっています。

https://github.com/yusukebe/hono-and-remix-on-vite

こちらはCloudflare Pagesで動作しますが、これをWorkersに移植した際つまづきポイントがいくつかあったため、執筆しました。

[remix-hono](https://github.com/sergiodxa/remix-hono)というリポジトリも存在し、2024年6月時点でアクティブなリポジトリであるものの、かゆいところに手が届かなかったので今回はyusukebeさんのリポジトリからフォークして作成しました。

https://github.com/sergiodxa/remix-hono

## 目指すゴール

- 開発環境（ローカル）でのホットリロード可
- 開発環境（ローカル）と本番環境（Workers）での差分が無い状態
    - フロント / バックはもちろん、静的ファイルのレスポンスもやる
    - 環境変数による分岐（`if`文）は許容

## やったこと

前述の[yusukebe/hono-and-remix-on-vite](https://github.com/yusukebe/hono-and-remix-on-vite)をWorkers用に書き直しました。

できたもののデモ
![hono-and-remix-on-workers-development.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/596394/186b3ddb-c6d2-b37d-80b5-13efe7d6e8bf.gif)

Cloudflare上の管理画面からも、Workersアプリであることがわかります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/596394/3422bbdf-8c3a-f49a-a55b-b849dc921b7b.png)

https://hono-and-remix-on-vite-at-workers.ogadra.workers.dev/

[差分はこちら](https://github.com/yusukebe/hono-and-remix-on-vite/compare/main...ogadra:hono-and-remix-on-vite-at-workers:main)

### package.json

```diff_json:package.json
"scripts": {
+  "deploy": "wrangler deploy server.ts",
+  "start" : "wrangler dev server.ts",
-  "deploy": "wrangler pages deploy",
-  "start" : "wrangler pages dev ./build/client",

```

PagesではなくWorkersにデプロイするため、デプロイコマンドを変更します。


### HonoからRemixを返す

```diff_typescript:server.ts
+ import { type AppLoadContext, createRequestHandler } from '@remix-run/cloudflare'
+ import * as build from './build/server'
- import { type AppLoadContext } from '@remix-run/cloudflare'
- import { staticAssets } from 'remix-hono/cloudflare'

// 中略

+ app.all('*', async(c) => {
- app.use(async(c, next) => {
+     const remixContext = {
+       cloudflare: { env: c.env }
+     } as unknown as AppLoadContext

      if (process.env.NODE_ENV !== 'development' || import.meta.env.PROD) {
+       // production
+       const handleRemixRequest = createRequestHandler(build, 'production')
+       return await handleRemixRequest(c.req.raw, remixContext)
-       return staticAssets()(c, next)
-     }
+     } else {
+       if (!handler) {
+         // @ts-expect-error it's not typed
+         const build = await import('virtual:remix/server-build')
+         const { createRequestHandler } = await import('@remix-run/cloudflare')
+         handler = createRequestHandler(build, 'development')
+       }
+       return handler(c.req.raw, remixContext)
+     }
-     await next()
  },
  // 後略

```

フロントエンドのレスポンスを返す際、Static Assetsではなく`@remix-run/cloudflare`の`createReqestHandler()`を使うようにします。
early returnしているためelse節は不要に思えますが、ないとBuildに失敗します。

### Honoから静的ファイルを返す

#### 結論

```diff_typescript:server.ts
+ import { getAssetFromKV } from '@cloudflare/kv-assert-handler'

// 中略

+ app.on(
+   'GET',
+   ['/assets/*', '/favicon.ico'], //assets配下とfaviconを静的ファイルとして指定
+   async (c) => {
+     if (process.env.NODE_ENV !== 'development' || import.meta.env.PROD) {
// if文の中でimportしないと、ローカルでは`Cannot find module`エラーになる
+       const manifest = await import('__STATIC_CONTENT_MANIFEST')
+       try {
+         return await getAssetFromKV({
+           request: c.req.raw,
+           waitUntil: c.executionCtx.waitUntil.bind(c.executionCtx)
+         }, {
+           ASSET_NAMESPACE: c.env.__STATIC_CONTENT,
+           ASSET_MANIFEST: JSON.parse(manifest.default),
+         })
+       } catch (e) {
+         return c.notFound()
+       }
+     }
+   }
+ )
```

#### 方針

hono + Cloudflare Workersで静的ファイルを使う方法は、

1. `hono/cloudflare-workers`の`serveStatic()`を使う
2. Cloudflare R2を使う
3. Cloudflare KVを使う
4. ファイル自体をimportする

の4つが思いつきます。今回は3のKVを使う方法を用いました。
1は2024年6月現在、`honojs/hono`にdeprecatedとしてマークされています。（静的ファイルを返すならPagesを使え、というのがCloudflareの方針でしょう。）

https://github.com/honojs/hono/issues/2580

4はファイルが増えるたび記述量を増やさなければならないので論外として、インターネット上に（そこそこ）例が転がっている3の方法を用いました。

#### Cloudflare KVを用いて静的ファイルを返す

`@cloudflare/kv-asset-handler`の`getAssetFromKV()`を用います。

https://developers.cloudflare.com/workers/configuration/sites/start-from-worker/#getting-started

基本上記の記事に従って書きますが、Honoを使うにあたって下記の点に注意してください。

上記の例では第一引数が
```typescript
{
    request,
    waitUntil: ctx.waitUntil.bind(ctx)
}
```
となっていますが、Honoを使う場合は
```typescript
{
    request: c.req.raw,
    waitUntil: c.executionCtx.waitUntil.bind(c.executionCtx)
}
```
となります。
今回はローカル環境で読み込めない`__STATIC_CONTENT_MANIFEST`を本番環境のみでimportしているのですが、その際のimport文が 
```typescript
const manifest = await import('__STATIC_CONTENT_MANIFEST')
```
となること、`ASSET_MANIFEST`の取り出し方が
```diff_typescript
- JSON.parse(manifest)
+ JSON.parse(manifest.default)
```
のように`default`が追加されることに注意してください。

#### ファイルの置き場所

静的ファイルの置き場所は`wrangler.toml`に書きます。
筆者は
```toml
[site]
bucket="./build/client
```
のようにして、ビルド時に吐き出されるパスを指定しました。

#### 備考 `getAssetFromKV()`の挙動について

```shell
$ tree .
.
├── assets
│   └── hono-logo.png
└── favicon.ico
```
上記のように設定した場合、Cloudflare KVには
```
{
    Key: assets/hono-logo.2ba06455c3.png
    Value: `This value contains unprintable characters. Download to view.`
}, {
    Key: favicon.dfd986c849.ico
    Value: `This value contains unprintable characters. Download to view.`
}
```
のようにして登録されます。

`favicon.ico`に対してリクエストが飛んだ場合、`getAssetFromKV()`関数は第2引数の`ASSET_MANIFEST`に渡された値（今回は`JSON.parse(manifest.default)`）からPATHである`favicon.ico`とKeyである`favicon.dfd986c849.ico`の対応関係を読み取りValueを返却しているようです。
そのため、`npm run deploy`実行時にTerminalへ表示されるファイル名がローカルのものと異なっていますが、PATHは変わらずリクエストすることができます。
このことは`JSON.parse(manifest.default)`をconsole文で出力すると分かりました。

## おわりに

Cloudflare workersでHono + Remixを静的ファイル含めて立ち上げることができました。
静的ファイルを大量に返す場合は素直にCloudflare Pagesを使ったほうが望ましいかと思いますが、小規模なアプリケーションを構築する場合ならこの程度で十分じゃないかなと思いました。