5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

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

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

注意

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

Viteアプリの作成

bun create vite myapp
  • テンプレートはReactTypeScript + 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エンコードしておき、エントリーポイントで読み込むようにします。

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からデコードして返します。

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にビルド用のコマンドを追加します。

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

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

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のサーバーを立ち上げます。

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をたたくボタンを追加します。

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;

ビルド

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

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

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?