【Bun】Vite+Honoアプリを単一実行ファイルにする

## 序

このところ、検証用のちょっとしたWebアプリを[`Vite`](https://ja.vitejs.dev/)と[`React`](https://ja.react.dev/)の組み合わせで作ることが多いのですが、非開発者の人に試用してもらうときに(彼 | 彼女らのPCには[`Node.js`](https://nodejs.jp/)が入っていないので)、いちいちこちらでサーバーを立てるの面倒だなぁと思っていました。

時期を同じくして、最近[Bun](https://bun.sh/)を試しています。Bunの[v1.1.5](https://bun.sh/blog/bun-v1.1.5)で、クロスコンパイル機能が追加され、Bunで動作するアプリを、さまざまな実行環境に向けて、実行可能バイナリに変換することが可能になりました。

これを使って試作品を楽に配布できないかなぁということで、やってみました。

## 注意
:::note warn
私が試した範囲では問題なく動作していましたが、あらゆるViteアプリがこの方法で完全に動作することは保証できません。
お試しの際は、ご自身の責任で十分に動作確認をするようにお願いします。
:::

## Viteアプリの作成

```bash
bun create vite myapp
```

- テンプレートは`React`と`TypeScript + SWC`で確認しましたが、おそらく他のでもいけると思います

```bash
cd myapp
bun install
bun run dev
```

追加で必要なパッケージと型定義をインストールしておきます
```bash
bun install --save-dev open @types/bun
```

## ビルド用のスクリプトを作成
`scripts`フォルダを作ります。

### `prepare.ts`
`bun build`で静的ファイルを組み込むためには、エントリーポイントになる`*.ts`ファイルで、[`import ~ from '~' with { type: "file" }`](https://bun.sh/docs/bundler/executables#embed-assets-files)とインポートする必要があります。
ただ、エントリーポイントの`*.ts`から動的に読み込もうとしたら、バイナリと一緒にViteのビルド結果が入った`dist`フォルダを一緒に配布しないとダメそうでした。
 - 実行時に読みだすのだから、それはそうだろうなという気はします

`dist`フォルダを配るのも嫌だったので、`dist`フォルダの中身を事前にbase64エンコードしておき、エントリーポイントで読み込むようにします。

```typescript:scripts/prepare.ts
import fs from 'fs';
import path from 'path';

const dist = path.resolve(__dirname, '../dist');
const files = fs.readdirSync(dist, { recursive: true });

// ファイルをbase64にエンコードしてroutes.jsonに書き出す
const promises = files
  .filter((fileName) => {
    // もしfileNameがディレクトリなら無視
    return !fs.statSync(path.resolve(dist, fileName.toString())).isDirectory();
  })
  .map(async (fileName) => {
    const absolutePath = path.resolve(dist, fileName.toString());
    const urlPath = path.join('/', fileName.toString());

    const file = fs.readFileSync(absolutePath);

    return {
      path: urlPath,
      contentType: Bun.file(absolutePath).type,
      base64: Buffer.from(file).toString('base64'),
    };
  });
// {path : {contentType,base64}}の形式に成形
const routes = (await Promise.all(promises)).reduce(
  (acc, { path, contentType, base64 }) => {
    acc[path] = { contentType, base64 };
    return acc;
  },
  {}
);
// /の時はindex.htmlと同じ内容を返す
routes['/'] = routes['/index.html'];

// ./routes.jsonを生成
fs.writeFileSync(
  path.resolve(__dirname, 'routes.json'),
  JSON.stringify(routes)
);
```

### `entry.ts`
エントリーポイントです。
こいつにさっき作った`routes.json`を食わせて、`url.pathname`に応じたコンテンツを、base64からデコードして返します。

```typescript:scripts/entry.ts
import routes from './routes.json';
const PORT = 5173;
const appUrl = `http://localhost:${PORT}`;

// Bunを使ってHTTPサーバーを立ち上げる
Bun.serve({
  port: PORT,
  fetch(req) {
    const url = new URL(req.url);
    const response = new Response(
      Buffer.from(routes[url.pathname].base64, 'base64').toString('utf-8')
    );
    response.headers.set('Content-Type', routes[url.pathname].contentType);
    return response;
  },
});

// コンソール表示
import { styleText } from 'node:util';

const announce = [
  [styleText('white', 'HTTP-server started:'), styleText('cyan', appUrl)],
  [styleText('white', 'Press Ctrl+C to stop the server.')],
];
announce.map((texts) => console.log(...texts));

// ブラウザを開く
import open from 'open';

open(appUrl);
```

## ビルドする

`package.json`にビルド用のコマンドを追加します。

```diff_javascript:package.json
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview",
+   "publish:win": "vite build --emptyOutDir && bun ./scripts/prepare.ts && bun build --compile --minify ./scripts/entry.ts --target=bun-windows-x64-modern --outfile myapp"
  },
```

<details><summary>コマンドの詳細
</summary>

```bash
vite build --emptyOutDir
```
viteのビルドコマンドです。デフォルトでは`./dist`フォルダにビルド結果が出力されます。
`--emptyOutDir`は出力先フォルダのクリンナップをするオプションです。

```bash
bun ./scripts/prepare.ts
```
先の`prepare.ts`を実行して、`routes.json`を生成します。

```bash
bun build --compile --minify ./scripts/entry.ts --target=bun-windows-x64-modern --outfile myapp
```
`bun build`はバンドル用のコマンドですが、`--compile`をつけると、実行可能バイナリにしてくれます。
- これはWindows用にビルドするコマンドです。`--target`、`--outfile`オプションは各々の環境に応じて変更してください
</details>

```bash
bun publish:win
```
これで同階層に饅頭アイコンの`myapp.exe`ができるはずです。

## 起動
`myapp.exe`をダブルクリックすると、ターミナルが開き、Webサーバーが立ち上がったうえでブラウザが開きます。

```bash
HTTP-server started: http://localhost:5173
Press Ctrl+C to stop the server.
```

**親の顔より見た**Vite+Reactのサンプルアプリが見えるはずです。

サーバーを終了する場合はSIGINTを投げるか、ターミナルを閉じればOKです。

## サーバーサイドも埋め込む
ここまで書いてから調べたら、[vite-plugin-singlefile](https://github.com/richardtallent/vite-plugin-singlefile)という、Viteアプリをhtml一個に全部まとめて`file://`プロトコルで動くようにしてくれるプラグインを見つけてしまいました。

これではこの記事の存在意義が消失してしまうので、バックエンドも埋め込んで差別化を図ります。

### [`Hono`](https://hono.dev/top)でAPIを作る

```bash
bun install hono
```

ファイルシステム操作を伴うエンドポイントを作ってみます。

```typescript:api/index.ts
import fs from 'fs';
import { Hono } from 'hono';
const app = new Hono();

const logFile = './log.txt';

app.get('/', (c) => {
  fs.appendFileSync(logFile, `${new Date()} : GET /\n`);
  return c.text('OK');
});

export { app };
```

`entry.ts`からhonoのサーバーを立ち上げます。

```diff_typescript:scripts/entry.ts
+ // hono APIサーバーを立ち上げる
+ import { app } from '../api';
+ const API_PORT = 3000;

+ Bun.serve({ fetch: app.fetch, port: API_PORT });

  import routes from './routes.json';
  const PORT = 5173;
  const appUrl = `http://localhost:${PORT}`;

  // Bunを使ってHTTPサーバーを立ち上げる
  Bun.serve({
    port: PORT,
    fetch(req) {
      const url = new URL(req.url);
      const response = new Response(
        Buffer.from(routes[url.pathname].base64, 'base64').toString('utf-8')
      );
      response.headers.set('Content-Type', routes[url.pathname].contentType);
      return response;
    },
  });

  // コンソール表示
  import { styleText } from 'node:util';
  
  const announce = [
    [styleText('white', 'HTTP-server started:'), styleText('cyan', appUrl)],
    [styleText('white', 'Press Ctrl+C to stop the server.')],
  ];
  announce.map((texts) => console.log(...texts));

  // ブラウザを開く
  import open from 'open';

  open(appUrl);
```

Viteの`App.tsx`にAPIをたたくボタンを追加します。

```diff_tsx:src/app.tsx
import { useState } from 'react';
import reactLogo from './assets/react.svg';
import viteLogo from '/vite.svg';
import './App.css';

function App() {
  const [count, setCount] = useState(0);

  return (
    <>
      <div>
        <a href="https://vitejs.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>Vite + React</h1>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
+     <button onClick={() => fetch('http://localhost:3000/')}>Call API</button>
    </>
  );
}

export default App;
```

### ビルド

```bash
bun publish:win
```

## 起動
`myapp.exe`をダブルクリックすると、ターミナルが開き、Webサーバーが立ち上がったうえでブラウザが開きます。

```bash
HTTP-server started: http://localhost:5173
Press Ctrl+C to stop the server.
```

**親の顔より見た**Vite+Reactのサンプルアプリの下のほうに、`Call API`というボタンがあるのでクリックしてみます。

すると、`myapp.exe`と同階層に`log.txt`が作成され、呼び出し時刻が記録されていきます。先ほど定義したHonoのエンドポイントが呼び出されていることがわかります。

これで、ViteとHonoの両方をバンドルした単一実行ファイルができました。

## 終わりに
~~無理やり記事にしましたが、読み返せば読み返すほど、何もこんなことせんでも、という気がしてきました。~~

実態はhttpサーバーを二つ立てているだけですし、ポート番号決め打ちにしてしまってるので、いろいろ問題はあると思いますが、試作品の配布など限られた用途においては、手軽だし用は満たせるかなと思いました。

あと、地味に厄介な問題として、実行ファイルのアイコンを変えることができません。あらゆるアプリが**饅頭**になるので、いろいろやってるとデスクトップがあっという間に饅頭で埋め尽くされ、**親の顔より饅頭を見る**ことになります。
ターミナルの住民であればさほど影響はありませんが、人に配るときはご注意ください。