🔥

Hono(Service Worker Magic)をVite x TSで実装できるようにする

2024/05/02に公開

モチベーション

WebAuthnのようなサーバーを必要とする機能を、ServiceWorkerでFIDOサーバ的なものを実装できるのか試しておきたいというのが当初の目的でした。

ServiceWorkerの実装を行うにあたって、技術選定を行なった結果、@yusukebeさんのService Worker Magicで実現するのが、良さそうだと判断しました。

https://yusukebe.com/posts/2022/service-worker-magic/

ただし、yusukebeさんのservice-worker-magicやgodaiさんのmonaco-browser-bundlerではTypeScriptで実装されていなかったため、実装を参考にさせてもらいつつ、TypeScriptで動かせるようにすることをゴールとしました。

リポジトリ

https://github.com/mktbsh/hono-sw-magic-ts?tab=readme-ov-file

デモサイト

https://hono-sw-magic-ts.vercel.app/

更新履歴

2024-05-06 追記

bun buildコマンドを利用したService Workerのビルドをやめました。

変更点1. Service Worker用のVite Configファイルを作成し、vite buildを利用する
mizchiさんのリポジトリを参考にさせていただきました。

https://github.com/mizchi/mytown

変更点2. ビルドHMRは効いても、ServiceWorkerの更新はブラウザリロードが必要な問題を修正

workboxを使っていれば、この辺の処理をいい感じにやってくれるのですが...

https://github.com/mktbsh/hono-sw-magic-ts/blob/main/src/client/utils/register-sw.ts

注意事項

  • とりあえず動く状態にしただけで、本運用に乗せられるような状態ではありません
  • 本記事では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コマンドを利用しています。

package.json
{
  "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内でも問題ないです)

/src/server/sw.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 もエクスポートする必要がある。

/src/server/app.ts
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を生成する関数をエクスポートする

/src/server/index.ts
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に下記コードを追記

/src/client/main.ts
navigator.serviceWorker.register('/sw.js', {
  scope: "/",
  type: "module"
})

実際にAPIリクエストを行う処理を実装して、動作確認をできるようにした最小限の実装

/src/client/App.tsx
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としてちゃんと整備したいなと考えています。

参考資料

https://yusukebe.com/posts/2022/service-worker-magic/

https://speakerdeck.com/steelydylan/honodereacttypescriptnoshi-xing-huan-jing-woburauzashang-nizuo-ru

https://github.com/yusukebe/service-worker-magic

https://github.com/steelydylan/monaco-browser-bundler

番外編

採用しなかった方法: esm.sh + Publicディレクトリ内でJS実装

godaiさんがmonaco-browser-bundlerで実装していた方法です。

Hono RPCを有効活用できないので採用しませんでした。

Noビルド、Noバンドルで動くので設定関連は不要で楽に実装をできます。
補完が効かないという問題も、Denoを有効化することで解決可能です。

.vscode/settings.json
{
  "deno.enable": false,
  "deno.enablePaths": [
    // ServiceWorkerの実装があるファイルパスを指定する
    "./public/sw.js"
  ],
  "deno.cacheOnSave": true,
  "deno.lint": true
}

※ Denoの機能は何も使いません。補完用に有効化するだけです。

Discussion