HonoでService Workerを扱いやすくする
こんにちは、sugar-catです。
この記事はHono Advent Calendar 2024の12日目の記事です。
はじめに
Honoではv4.5系でService Workerのアダプターを使用することができるようになりました。
このアダプターを使用することで、リクエストのインターセプトや各種処理をHonoの書き味のまま実装することが可能になります。
Service Workerについて
Service Workerは、ブラウザとネットワークの間でプロキシとして機能します。
ブラウザのバックグラウンドで動作し、キャッシュやプッシュ通知などのタスクを処理するスクリプトとして利用されています。
ライフサイクルについては、以下の動画がわかりやすいです。
このService Workerには、fetch
イベントがあります。
このfetch
イベントはメインスレッドがネットワークリクエストを行う際に、サービスワーカーのグローバルスコープで発生します。
サービスワーカー側でイベントリスナーを定義(addEventListener("fetch", (event) => {});
)し、イベントハンドラーでメインスレッドのレスポンスを差し替えることが可能です。(要はリクエストをProxyできます)
そしてこのハンドラーにHonoのアダプターを噛ませることでService Workerの開発がしやすくなります。
イベントはスクリプトを登録したServiceWorkerGlobalScope
に配信されます。
以下に処理の流れを示します。
- ブラウザが
ServiceWorkerGlobalScope
を生成します。 -
fetch
イベントのコールバックを提供します。 - サービスワーカー内のハンドラがHonoで実装されており、リクエストを処理します。
使い方
Service Workerの登録
Service Workerを登録するには、クライアント側でServiceWorkerContainer.register()
を使用します。
navigator.serviceWorker.register('/service-worker.js', {
scope: '/sw',
type: 'module',
updateViaCache: 'none',
});
Service Worker内のスクリプトでHonoを利用する
次に、Service WorkerのFetchEventのハンドラーとしてHonoを利用する方法を紹介します。
// https://github.com/microsoft/TypeScript/issues/14877
declare const self: ServiceWorkerGlobalScope;
import { Hono } from 'hono';
import { handle } from 'hono/service-worker';
const app = new Hono().basePath('/sw'); // クライアント側のService Workerのスコープを指定
app.get('/', (c) => c.text('Hello World'));
self.addEventListener('fetch', handle(app));
hono/service-worker
のアダプターが提供されており、Honoインスタンスをそのまま渡すだけで、インターセプトしたいfetchのパスを通常のAPIと同様に記述できます。
このスクリプトをお好みのツールでトランスパイルし、公開されているディレクトリに配置することでService Workerが有効になります。
上記の例では、クライアント側からfetch('/sw')
でリクエストが来た場合、仮に/sw
というエンドポイントで定義されたAPIが存在しても、Hello World
というテキストを返すようになります。
Next.jsでService Workerを利用する(実践)
実際の開発では、様々なライブラリと組み合わせてService Workerを利用することが多いです。
ここではNext.js (App Router) とHono、Service Workerを組み合わせて、簡単なChatGPTライクなアプリを作成します。
UIは適当にv0で作成しています。
コード全体は下記にあります。
まず、OpenAIのストリームを返すエンドポイントを作成します。
今回は簡単のため、Next.js上でAPI Routesで作成します。
Next.jsのAPI RoutesでもVercelのアダプターでHonoを利用することができます。
OpenAI自体はVercelのAI SDKを利用し、HonoのStream Helperを利用して実装を最小限に抑えます。
import { createOpenAI } from '@ai-sdk/openai';
import { streamText } from 'ai';
import { Hono } from 'hono';
import { stream } from 'hono/streaming';
import { handle } from 'hono/vercel';
export const runtime = 'edge';
const openai = createOpenAI({
compatibility: 'strict',
organization: "xxx",
project: "xxx",
apiKey: "xxx",
baseURL: "xxx",
});
const app = new Hono().basePath('/api');
app.post('/chat', async (c) => { // /api/chatでストリームを返す
const r = await c.req.json();
return stream(c, async (stream) => {
const result = await streamText({
model: openai('gpt-4o-mini'),
messages: r.messages,
});
await stream.pipe(result.toDataStream());
});
});
export const GET = handle(app);
export const POST = handle(app);
次に、クライアント側の定義です。
SSEを扱いやすくするために、Vercelが提供しているAI SDK
のuseChat
hooksを利用します。
'use client';
import { useChat } from 'ai/react';
// 略
export default function Page() {
const { messages, input, handleInputChange, handleSubmit } = useChat({
api: '/sw/chat', // Service Workerで定義したエンドポイントを叩く
});
useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/sw.js", { scope: "/", type: "module" }) // Service Workerを登録
.then(
function (_registration) {
console.log("Service Workerの登録に成功しました");
},
function (_error) {
console.log("Service Workerの登録に失敗しました");
}
);
}
}, []);
// 省略
}
次に、Service Workerの定義です。
トランスパイル前のコードなのでTypeScriptで記述しています。
ServiceWorkerGlobalScope
の型補完を有効にするために、tsconfig
のcompilerOptions
に"lib": ["webworker"]
を追加しています。
/sw/chat
にリクエストが来た場合に、/api/chat
にリクエストをプロキシし、そのレスポンスをSSEで返すようにします。
クライアント側で使用しているuseChat
では内部的にPOSTリクエストを送信しており、Service Workerを利用してこのリクエストをインターセプトし、OpenAIのストリームを受け取ります。
どのようなリクエストであってもfetchを通るものであればインターセプト可能であり、ライブラリ内で抽象化されたAPIリクエストでも問題ありません。
import { Hono } from "hono";
import { handle } from "hono/service-worker";
import { streamText } from "hono/streaming";
// https://github.com/microsoft/TypeScript/issues/14877
declare const self: ServiceWorkerGlobalScope;
const app = new Hono().basePath("/sw");
app.post("/chat", async (c) => { // /sw/chatにリクエストが来た場合にインターセプト
const { messages } = await c.req.json();
return streamText(c, async (stream) => {
const response = await fetch('/api/chat', { // API Routesのchatエンドポイントにリクエストをプロキシ
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ messages }),
});
if (!response.body) {
await stream.write('data: [ERROR] レスポンスボディが存在しません\n\n');
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
await stream.write(text);
}
} finally {
await stream.write('data: [DONE]\n\n');
reader.releaseLock();
}
});
});
self.addEventListener("fetch", handle(app));
上記を動作させると、Service Workerを利用してAPIへリクエストが飛んでいることが確認できます。(歯車アイコンがService Worker)
この実装では単純にリクエストを受け流していますが、応用すればクライアント側での入力値のサニタイズや、リクエストのキャッシュなど様々な処理をメインスレッドから分離してService Workerで行うことが可能です。
まとめ
Honoを組み合わせてService Workerを開発することで、FetchEventの扱いが少し楽になります。
Discussion