序
このところ、検証用のちょっとしたWebアプリをVite
とReact
の組み合わせで作ることが多いのですが、非開発者の人に試用してもらうときに(彼 | 彼女らのPCにはNode.js
が入っていないので)、いちいちこちらでサーバーを立てるの面倒だなぁと思っていました。
時期を同じくして、最近Bunを試しています。Bunのv1.1.5で、クロスコンパイル機能が追加され、Bunで動作するアプリを、さまざまな実行環境に向けて、実行可能バイナリに変換することが可能になりました。
これを使って試作品を楽に配布できないかなぁということで、やってみました。
注意
私が試した範囲では問題なく動作していましたが、あらゆるViteアプリがこの方法で完全に動作することは保証できません。
お試しの際は、ご自身の責任で十分に動作確認をするようにお願いします。
Viteアプリの作成
bun create vite myapp
- テンプレートは
React
とTypeScript + SWC
で確認しましたが、おそらく他のでもいけると思います
cd myapp
bun install
bun run dev
追加で必要なパッケージと型定義をインストールしておきます
bun install --save-dev open @types/bun
ビルド用のスクリプトを作成
scripts
フォルダを作ります。
prepare.ts
bun build
で静的ファイルを組み込むためには、エントリーポイントになる*.ts
ファイルで、import ~ from '~' with { type: "file" }
とインポートする必要があります。
ただ、エントリーポイントの*.ts
から動的に読み込もうとしたら、バイナリと一緒にViteのビルド結果が入ったdist
フォルダを一緒に配布しないとダメそうでした。
- 実行時に読みだすのだから、それはそうだろうなという気はします
dist
フォルダを配るのも嫌だったので、dist
フォルダの中身を事前にbase64エンコードしておき、エントリーポイントで読み込むようにします。
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からデコードして返します。
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
にビルド用のコマンドを追加します。
"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"
},
コマンドの詳細
vite build --emptyOutDir
viteのビルドコマンドです。デフォルトでは./dist
フォルダにビルド結果が出力されます。
--emptyOutDir
は出力先フォルダのクリンナップをするオプションです。
bun ./scripts/prepare.ts
先のprepare.ts
を実行して、routes.json
を生成します。
bun build --compile --minify ./scripts/entry.ts --target=bun-windows-x64-modern --outfile myapp
bun build
はバンドル用のコマンドですが、--compile
をつけると、実行可能バイナリにしてくれます。
- これはWindows用にビルドするコマンドです。
--target
、--outfile
オプションは各々の環境に応じて変更してください
bun publish:win
これで同階層に饅頭アイコンのmyapp.exe
ができるはずです。
起動
myapp.exe
をダブルクリックすると、ターミナルが開き、Webサーバーが立ち上がったうえでブラウザが開きます。
HTTP-server started: http://localhost:5173
Press Ctrl+C to stop the server.
親の顔より見たVite+Reactのサンプルアプリが見えるはずです。
サーバーを終了する場合はSIGINTを投げるか、ターミナルを閉じればOKです。
サーバーサイドも埋め込む
ここまで書いてから調べたら、vite-plugin-singlefileという、Viteアプリをhtml一個に全部まとめてfile://
プロトコルで動くようにしてくれるプラグインを見つけてしまいました。
これではこの記事の存在意義が消失してしまうので、バックエンドも埋め込んで差別化を図ります。
Hono
でAPIを作る
bun install hono
ファイルシステム操作を伴うエンドポイントを作ってみます。
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のサーバーを立ち上げます。
+ // 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をたたくボタンを追加します。
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;
ビルド
bun publish:win
起動
myapp.exe
をダブルクリックすると、ターミナルが開き、Webサーバーが立ち上がったうえでブラウザが開きます。
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サーバーを二つ立てているだけですし、ポート番号決め打ちにしてしまってるので、いろいろ問題はあると思いますが、試作品の配布など限られた用途においては、手軽だし用は満たせるかなと思いました。
あと、地味に厄介な問題として、実行ファイルのアイコンを変えることができません。あらゆるアプリが饅頭になるので、いろいろやってるとデスクトップがあっという間に饅頭で埋め尽くされ、親の顔より饅頭を見ることになります。
ターミナルの住民であればさほど影響はありませんが、人に配るときはご注意ください。