Zenn

Bun で HTML をライブリロードする

2025/02/07に公開
2

tl;dr

bun-html-live-reload というBunでライブリロードできるライブラリを作ったので、コードが動く仕組みを簡略化してこの記事に書いてます。
一言で説明すると、Bunサーバーがホットリロードされるたびに、Server Sent Events (SSE) を送信してブラウザを更新してます。

何が不満なのか

Bunの組み込みHTTPサーバーを使うと簡単にウェブサイトが作れちゃいます。

// server.ts
Bun.serve({
  fetch: () => {
    return new Response("<h1>Hello, World!</h1>", {
      headers: { "Content-Type": "text/html" },
    });
  },
});

bun --hot server.tsコマンドを実行すればホットリロードモードでサーバーが動きます。 これでソースコードのHTMLを編集するたびにサーバーががリロードされるわけです。 ただ、サーバーがリロードされても、ブラウザまでは更新されません。ソースコードを編集したあと、手動でブラウザの更新ボタンを押さなければいけません。

ですが理想はブラウザの方もこのように自動で更新してくれることです。こういう動作をライブリロードと言います。

この記事では、HTMLコンテンツのための簡単なライブリロードの仕組みを作っていきます。 server.tsファイルや、それが読み込む他のファイルを変更するたびに、 ブラウザが自動的にページを更新して、すぐに変更が反映されるようになります。

ユーザーから見ると、最終的にはこんな感じのコードになります:

// server.ts
import { withHtmlLiveReload } from "bun-html-live-reload";

Bun.serve({
  fetch: withHtmlLiveReload(async (req) => {
    return new Response("<h1>Hello, World!</h1>", {
      headers: { "Content-Type": "text/html" },
    });
  }),
});

何を使うか (Server Sent Events)

仕組み自体はすごくシンプルで、サーバーがリロードされるたびに、ブラウザにページの更新を指示するだけです。 これを実現するには、WebSocketsかServer Sent Events (SSE)を使う方法があります。

今回は以下の理由でSSEを使うことにしました:

  • メッセージが一方向(サーバーからブラウザへ)で、双方向である必要がない
  • WebSocketのように接続のアップグレードが必要ない

それに加えてSSEメッセージの送信はとても簡単で、必要なのは2つだけ。

  • メッセージを送信するエンドポイントを用意する
  • すべてのHTMLレスポンスにSSEを待ち受けるスクリプトを組み込む

Bunのホットリロードを理解する

リロードメッセージを送信する前に、Bunのホットリロードがどう動くか理解する必要があります。 次のコードをbun --hot server.tsで実行してみましょう:

// server.ts
let message = "hello";
console.log(message);

予想通り、サーバーはhelloをコンソールに表示します。

ターミナルはそのままで、メッセージを編集して保存してみましょう。

// server.ts
let message = "hello world";
console.log(message);

サーバーはhello worldをコンソールに表示します。

つまり、ファイルを変更するたびに、Bunはそのファイルのトップレベルのコードを再実行するということです。

次の章では、リロード間で変数を保持する必要が出てくるのでglobalThisを使ってやってみましょう。

globalThis.counter = globalThis.counter ?? 0;
globalThis.counter += 1;

console.log("Counter: ", counter);

こう書くと、コードを変更するたびに、カウンターが1ずつ増えていきます。 ちゃんとリロード間で変数が共有されてますね。

TypeScriptでの型安全性のために、グローバルスコープで変数の型を定義できます。

declare global {
  var counter: number | undefined;
}

globalThis.counter = globalThis.counter ?? 0;
globalThis.counter += 1;

console.log("Counter: ", counter);

SSEイベントの待ち受ける

ブラウザでSSEイベントを待ち受けるために、EventSource APIを使います。 /__bun_live_reloadエンドポイントをイベントのソースとして使い、 イベントを受け取るたびにlocation.reload()でブラウザを更新します。

new EventSource("/__bun_live_reload").onmessage = () => {
  location.reload();
};

このスクリプトは後の章で、すべてのHTMLレスポンスに組み込みます。

// TODO: このスクリプトをすべてのHTMLレスポンスに組み込む
const liveReloadScript = `
<script>
  new EventSource("/__bun_live_reload").onmessage = () => {
    location.reload();
  };
</script>
`;

スクリプトの組み込み

すべてのHTMLレスポンスにスクリプトを組み込むために、fetch関数のラッパーを作ります。

// bun-html-live-reload.ts
type Fetch = (req: Request) => Promise<Response>;
export function withHtmlLiveReload(handler: Fetch): Fetch {
  return async (req) => {
    const response = await handler(req);
    // TODO: すべてのHTMLレスポンスにスクリプトを組み込む
    return response;
  };
}

このチュートリアルでは簡潔にライブリロードスクリプトをHTMLコンテンツの最後に追加するだけにします。

export function withHtmlLiveReload(handler: Fetch): Fetch {
  return async (req) => {
    const response = await handler(req);
    const htmlText = await response.text();
    const newHtmlText = htmlText + liveReloadScript;
    return new Response(newHtmlText, { headers: response.headers });
  };
}

SSEメッセージの送信

SSEメッセージを送信するために、新しいエンドポイント/__bun_live_reloadを作成し、レスポンスとしてReadableStreamオブジェクトを返す必要があります。

Content-Typeヘッダーにtext/event-streamを設定するのを忘れずに!

export function withHtmlLiveReload(handler: Fetch): Fetch {
  return async (req) => {
    const response = await handler(req);

    if (new URL(req.url).pathname === "/__bun_live_reload") {
      const stream = new ReadableStream({
        start(client) {},
      });
      return new Response(stream, {
        headers: {
          "Content-Type": "text/event-stream",
          "Cache-Control": "no-cache",
        },
      });
    }

    // ...
    return response;
  };
}

そして、clientオブジェクトをグローバル変数として保存します。
次のリロード時に、トップレベルでenqueueメソッドを呼び出すことでクライアントにメッセージを送信できます。

// withHtmlLiveReload
const stream = new ReadableStream({
  start(client) {
    globalThis.client = client;
  },
});
// withHtmlLiveReload

// これでクライアントにメッセージが送信されます
globalThis.client?.enqueue("data:\n\n");

メッセージ文字列data:\n\nは、イベントをトリガーするために必要な最小限のメッセージです。クライアントに送信したいデータがあれば、ここに追加できます。

また、これの型定義も追加しておくと良いでしょう

declare global {
  var client: ReadableStreamDefaultController | undefined;
}

まとめ

こちらが最終形態になります。

// bun-html-live-reload.ts
declare global {
  var client: ReadableStreamDefaultController | undefined;
}

type Fetch = (req: Request) => Promise<Response>;

globalThis.client?.enqueue("data:\n\n");

const liveReloadScript = `
  <script>
    new EventSource("/__bun_live_reload").onmessage = () => {
      location.reload();
    };
  </script>
`;

export function withHtmlLiveReload(handler: Fetch): Fetch {
  return async (req) => {

    if (new URL(req.url).pathname === "/__bun_live_reload") {
      const stream = new ReadableStream({
        start(client) {
          globalThis.client = client;
        },
      });
      return new Response(stream, {
        headers: {
          "Content-Type": "text/event-stream",
          "Cache-Control": "no-cache",
        },
      });
    }

    const response = await handler(req);
    const htmlText = await response.text();
    const newHtmlText = htmlText + liveReloadScript;
    return new Response(newHtmlText, { headers: response.headers });
  };
}
2

Discussion

ログインするとコメントできます