📕

HonoでService Workerを扱いやすくする

2024/12/12に公開

こんにちは、sugar-catです。

この記事はHono Advent Calendar 2024の12日目の記事です。
https://qiita.com/advent-calendar/2024/hono

はじめに

Honoではv4.5系でService Workerのアダプターを使用することができるようになりました。
このアダプターを使用することで、リクエストのインターセプトや各種処理をHonoの書き味のまま実装することが可能になります。

Service Workerについて

Service Workerは、ブラウザとネットワークの間でプロキシとして機能します。
ブラウザのバックグラウンドで動作し、キャッシュやプッシュ通知などのタスクを処理するスクリプトとして利用されています。

https://developer.mozilla.org/ja/docs/Web/API/Service_Worker_API

ライフサイクルについては、以下の動画がわかりやすいです。
https://www.youtube.com/watch?v=AIdQkNYsViM

このService Workerには、fetchイベントがあります。
https://developer.mozilla.org/ja/docs/Web/API/FetchEvent

このfetchイベントはメインスレッドがネットワークリクエストを行う際に、サービスワーカーのグローバルスコープで発生します。
サービスワーカー側でイベントリスナーを定義(addEventListener("fetch", (event) => {});)し、イベントハンドラーでメインスレッドのレスポンスを差し替えることが可能です。(要はリクエストをProxyできます)

そしてこのハンドラーにHonoのアダプターを噛ませることでService Workerの開発がしやすくなります。
https://hono.dev/docs/getting-started/service-worker

イベントはスクリプトを登録したServiceWorkerGlobalScopeに配信されます。
以下に処理の流れを示します。

  1. ブラウザがServiceWorkerGlobalScopeを生成します。
  2. fetchイベントのコールバックを提供します。
  3. サービスワーカー内のハンドラがHonoで実装されており、リクエストを処理します。

使い方

Service Workerの登録

Service Workerを登録するには、クライアント側でServiceWorkerContainer.register()を使用します。

https://developer.mozilla.org/ja/docs/Web/API/ServiceWorkerContainer/register#updateviacache

client.ts
navigator.serviceWorker.register('/service-worker.js', {
  scope: '/sw',
  type: 'module',
  updateViaCache: 'none',
});

Service Worker内のスクリプトでHonoを利用する

次に、Service WorkerのFetchEventのハンドラーとしてHonoを利用する方法を紹介します。

https://hono.dev/docs/getting-started/service-worker

service-worker.ts
// 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と同様に記述できます。

https://github.com/honojs/hono/blob/47bb23c575a93d5fed4721935481a6a4cbf5cf1a/src/adapter/service-worker/handler.ts#L14-L34

このスクリプトをお好みのツールでトランスパイルし、公開されているディレクトリに配置することでService Workerが有効になります。

上記の例では、クライアント側からfetch('/sw')でリクエストが来た場合、仮に/swというエンドポイントで定義されたAPIが存在しても、Hello Worldというテキストを返すようになります。

Next.jsでService Workerを利用する(実践)

実際の開発では、様々なライブラリと組み合わせてService Workerを利用することが多いです。
ここではNext.js (App Router) とHono、Service Workerを組み合わせて、簡単なChatGPTライクなアプリを作成します。
UIは適当にv0で作成しています。

alt text

コード全体は下記にあります。
https://github.com/sugar-cat7/example-hono-service-worker

まず、OpenAIのストリームを返すエンドポイントを作成します。
今回は簡単のため、Next.js上でAPI Routesで作成します。

Next.jsのAPI RoutesでもVercelのアダプターでHonoを利用することができます。
https://hono.dev/docs/getting-started/vercel

OpenAI自体はVercelのAI SDKを利用し、HonoのStream Helperを利用して実装を最小限に抑えます。
https://sdk.vercel.ai/docs/introduction

app/api/[[...route]]/route.ts
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 SDKuseChathooksを利用します。
https://sdk.vercel.ai/docs/reference/ai-sdk-ui/use-chat

app/page.tsx
'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の型補完を有効にするために、tsconfigcompilerOptions"lib": ["webworker"]を追加しています。
/sw/chatにリクエストが来た場合に、/api/chatにリクエストをプロキシし、そのレスポンスをSSEで返すようにします。

クライアント側で使用しているuseChatでは内部的にPOSTリクエストを送信しており、Service Workerを利用してこのリクエストをインターセプトし、OpenAIのストリームを受け取ります。
どのようなリクエストであってもfetchを通るものであればインターセプト可能であり、ライブラリ内で抽象化されたAPIリクエストでも問題ありません。

lib/sw.ts
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)
alt text

この実装では単純にリクエストを受け流していますが、応用すればクライアント側での入力値のサニタイズや、リクエストのキャッシュなど様々な処理をメインスレッドから分離してService Workerで行うことが可能です。

まとめ

Honoを組み合わせてService Workerを開発することで、FetchEventの扱いが少し楽になります。

Discussion