Hono(Service Worker Magic)をVite x TSで実装できるようにする
モチベーション
WebAuthnのようなサーバーを必要とする機能を、ServiceWorkerでFIDOサーバ的なものを実装できるのか試しておきたいというのが当初の目的でした。
ServiceWorkerの実装を行うにあたって、技術選定を行なった結果、@yusukebeさんのService Worker Magicで実現するのが、良さそうだと判断しました。
ただし、yusukebeさんのservice-worker-magicやgodaiさんのmonaco-browser-bundlerではTypeScriptで実装されていなかったため、実装を参考にさせてもらいつつ、TypeScriptで動かせるようにすることをゴールとしました。
リポジトリ
デモサイト
更新履歴
2024-05-06 追記
bun buildコマンドを利用したService Workerのビルドをやめました。
変更点1. Service Worker用のVite Configファイルを作成し、vite build
を利用する
mizchiさんのリポジトリを参考にさせていただきました。
変更点2. ビルドHMRは効いても、ServiceWorkerの更新はブラウザリロードが必要な問題を修正
workboxを使っていれば、この辺の処理をいい感じにやってくれるのですが...
注意事項
- とりあえず動く状態にしただけで、本運用に乗せられるような状態ではありません
本記事ではBunの機能に依存しています。他のパッケージマネージャーを利用している場合はビルドコマンド/処理をesbuildを利用する方法に変更する必要があります。-
bun build
のJavaScript APIにはwatchオプションがなく、Hot reloadを効かせるためにCLIを利用しています- issueは起票されています #5866
要件
優先度 | 項目 |
---|---|
Must | HonoのRPC機能を使ってクライアントからのリクエストは楽をしたい |
Must | IDEによる補完が効くこと |
Must | サーバー(Node.js, Edge系)でも動かせること(ポータビリティ) |
Optional | Service WorkerのLifeCycle周りをいい感じにしたい |
Optional | プラグイン/ライブラリ化したい(他のプロジェクトでも簡単に使えるようにしたい) |
Optional | MSWのようにCLIで生成できるようにしたい |
プロジェクト構成
.
├── .vscode
├── public/
│ └── sw.js
├── src/
│ ├── assets
│ ├── client/
│ │ ├── App.tsx
│ │ └── main.tsx
│ └── server/
│ ├── routes/
│ │ ├── foo.ts -> /foo
│ │ └── bar.ts -> /bar
│ ├── app.ts -> Honoインスタンス生成
│ ├── index.ts -> client側に公開するインターフェース等をexportする用途
│ └── sw.ts -> ServiceWorkerのエントリーポイント
├── .gitignore
├── bun.lockb
├── index.html
├── package.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
npm-scripts
注意事項で前述したとおり、Bun.build
にはwatchオプションがないため、bun build
コマンドを利用しています。
{
"scripts": {
"dev": "run-p dev:*",
"dev:sw": "bun build ./src/server/sw.ts --outdir ./public --format esm --sourcemap=inline --watch",
"dev:vite": "vite",
"build": "run-s build:*",
"build:sw": "bun build ./src/server/sw.ts --outdir ./public --format esm --minify --sourcemap=none",
"build:vite": "tsc && vite build",
},
"dependencies": {
"hono": "",
},
"devDependencies": {
"npm-run-all2": "",
"vite": ""
}
}
devとbuildコマンドでの違いは以下の通りです。
- sourcemap: devモード時のみinline化
- minify: production build時のみ
Service Workerの実装をする
基本的にHono周りの実装はすべてapp.ts
と関連ファイルに収束させ、sw.ts
ではServiceWorkerのみに関心を向けるようにします。
notFoundだけはServiceWorkerを考慮した実装をしたりしているので、sw.tsで設定しています。(app.ts内でも問題ないです)
import { app } from "./app";
import { notFoundHandler } from "./utils/not-found";
const _self = self as unknown as ServiceWorkerGlobalScope;
_self.addEventListener("install", function () {
_self.skipWaiting();
});
_self.addEventListener("activate", function (event) {
event.waitUntil(_self.clients.claim());
});
app.notFound(notFoundHandler);
app.fire();
API(hono)の実装
app.tsでHono周りの諸々の実装を行う。
ServiceWorker(sw.ts
)で利用するため、変数appのエクスポートは必須。
また、RPCを利用するため、型定義 AppType
もエクスポートする必要がある。
import { Hono } from "hono";
import { logger } from "hono/logger";
const BUILD_TIME = Date.now();
export const app = new Hono().basePath("/sw");
app.use(logger());
export type AppType = typeof route;
const route = app
.get("/version", (c) => c.text(`v${BUILD_TIME}`))
.get("/health", (c) => c.json({ ok: true, ts: Date.now() }));
クライアントサイドではhonoを意識しなくてもいいように、HonoClientを生成する関数をエクスポートする
import { AppType } from './app'
import { hc } from 'hono/client'
type Options = Parameters<typeof hc>[1];
export function createClient(baseURL: string = '', options?: Options) {
return hc<AppType>(baseURL, options);
}
クライアントサイドの実装
main.tsに下記コードを追記
navigator.serviceWorker.register('/sw.js', {
scope: "/",
type: "module"
})
実際にAPIリクエストを行う処理を実装して、動作確認をできるようにした最小限の実装
import { createClient } from '@/server'
// hono clientを生成
const client = createClient(location.origin);
function App() {
async function handleClick() {
const response = await client.sw.health.$get()
const json = await response.json();
console.log(json); // { ok: true, ts: <UNIX_TIMESTAMP> }
}
return <button onClick={handleFetch} type='button'>Click</button>;
}
export default App
終わりに
ビルド周りの最適化ができるようになったらHonoのadapterか、ViteのPluginとしてちゃんと整備したいなと考えています。
参考資料
番外編
採用しなかった方法: esm.sh + Publicディレクトリ内でJS実装
godaiさんがmonaco-browser-bundlerで実装していた方法です。
Hono RPCを有効活用できないので採用しませんでした。
Noビルド、Noバンドルで動くので設定関連は不要で楽に実装をできます。
補完が効かないという問題も、Denoを有効化することで解決可能です。
{
"deno.enable": false,
"deno.enablePaths": [
// ServiceWorkerの実装があるファイルパスを指定する
"./public/sw.js"
],
"deno.cacheOnSave": true,
"deno.lint": true
}
※ Denoの機能は何も使いません。補完用に有効化するだけです。
Discussion
続編が見たい