Skip to content

Instantly share code, notes, and snippets.

@podhmo
Created November 15, 2025 01:23
Show Gist options
  • Select an option

  • Save podhmo/cd4f83486188b8c9b4b2bd2cec132a82 to your computer and use it in GitHub Desktop.

Select an option

Save podhmo/cd4f83486188b8c9b4b2bd2cec132a82 to your computer and use it in GitHub Desktop.
SSEの利用例をフロントエンドだけでデモしたいしビルドしたくない

対話履歴

ユーザー: SSEの利用例のデモを作るのにおすすめを挙げてください。コード例は不要です。たくさんの例を挙げてください。


AI: はい、承知いたしました。SSE (Server-Sent Events) の利用例として、デモ作成に最適なアイデアをたくさん挙げます。コード例は含めません。

SSEは「サーバーからクライアントへの一方向のプッシュ通知」という特性を持っているため、それを活かせるシナリオがデモとして効果的です。


入門・定番の例

SSEの基本を理解するのに最適な、シンプルで分かりやすいデモです。

  1. リアルタイム時刻表示

    • サーバーが1秒ごとに現在時刻をクライアントに送信し、ブラウザの時計がリアルタイムで更新され続けるデモ。最も基本的な実装例です。
  2. ニュース速報ティッカー

    • 画面の上部や下部に、サーバーからプッシュされたニュース見出しが次々と流れていくデモ。
  3. ランダムメッセージジェネレーター

    • サーバーが数秒おきにランダムな名言やメッセージを生成し、クライアントに送信するデモ。手軽に「プッシュ」を体感できます。

リアルタイムダッシュボード系の例

複数の情報を一度に、かつリアルタイムで表示するダッシュボードはSSEの得意分野です。

  1. サーバー監視ダッシュボード

    • サーバーのCPU使用率、メモリ使用量、ネットワークトラフィックなどをリアルタイムでグラフ表示します。バックグラウンドで値を生成し、定期的にクライアントに送信します。
  2. 株価・仮想通貨の価格トラッカー

    • 特定の銘柄の価格が変動するたびに、サーバーが最新価格をクライアントに送信します。価格が上がれば緑、下がれば赤で表示するなど、視覚的な変化を付けると効果的です。
  3. Webサイトのリアルタイムアクセス解析

    • 「現在の訪問者数」「リアルタイムのページビュー」「どのページが見られているか」といった情報をリアルタイムで更新する管理者向けダッシュボード。
  4. スポーツの試合速報

    • 野球やサッカーなどの試合で、得点が入ったり、状況が変化したりするたびに、スコアボードやテキスト速報を更新します。
  5. 天気情報のリアルタイム更新

    • 特定の地域の気温、湿度、風速などをセンサーやAPIから取得し、リアルタイムでダッシュボードに表示します。

ソーシャル・コミュニケーション系の例

ユーザーのアクションを他のユーザーに即座に伝えるシナリオです。

  1. ライブコメントフィード

    • 動画配信やブログ記事の下にあるコメント欄に、新しいコメントが投稿されたらページをリロードせずに自動で追加表示します。
  2. 「〇〇さんが入力中です...」の表示

    • チャットアプリで、相手が文字を入力し始めたことをサーバーが検知し、他の参加者に「入力中」イベントを送信します。(双方向通信が不要な場合に有効)
  3. アクティビティーストリーム(通知フィード)

    • SNSで「〇〇さんがあなたの投稿に『いいね!』しました」「△△さんがあなたをフォローしました」といった通知をリアルタイムで表示します。
  4. ライブリアクションカウンター

    • ライブ配信中に視聴者が送った「ハート」や「いいね」のアイコンを画面上に表示し、合計カウントをリアルタイムで増やしていくデモ。

Eコマース・ビジネス系の例

ビジネスシーンでの実用的な利用例です。

  1. 新規注文のリアルタイム通知

    • ECサイトの管理者向け画面で、新しい注文が入るたびに「新規注文がありました!」という通知をポップアップやリストで表示します。
  2. 在庫数のリアルタイム更新

    • 人気商品の商品ページで、他のユーザーが購入するたびに在庫数がリアルタイムで減っていく様子を表示します。
  3. オンラインオークションの入札状況

    • 現在の最高入札額と入札者情報が、新しい入札があるたびに全参加者の画面で即座に更新されます。
  4. 配達状況のリアルタイム追跡

    • フードデリバリーや配送サービスで、配達員の現在位置を地図上でリアルタイムに更新します。

進捗・ステータス更新系の例

時間のかかる処理の状況をユーザーに伝えるのに役立ちます。

  1. ファイルのアップロード・変換処理のプログレスバー

    • ユーザーがアップロードした動画のエンコードなど、サーバー側での時間のかかる処理の進捗状況(例: 10%完了... 50%完了...)をリアルタイムで伝えます。
  2. 大規模データ処理のパイプライン状況

    • 「データ収集中 → データ整形中 → 分析実行中 → 完了」といった複数のステップからなる処理の現在のステータスをリアルタイムで表示します。
  3. サーバーログのライブストリーミング

    • サーバー上で生成されるログを、Webブラウザの画面にリアルタイムで流し続けます。開発者向けのツールとして非常に有用です。

IoT・センサーデータ系の例

物理世界のデータをWebに繋げるデモです。

  1. スマートホームのセンサーダッシュボード

    • 部屋の温度センサー、ドアの開閉センサー、人感センサーなどの状態変化をリアルタイムでWeb画面に反映させます。
  2. 工場の生産ライン監視

    • ベルトコンベアの稼働状況や、機器の温度・圧力といったセンサーデータをリアルタイムで可視化します。

インタラクティブ・エンタメ系の例

ユーザー参加型の楽しいデモです。

  1. リアルタイム投票・アンケート

    • セミナーやイベントで、参加者が投票すると、その結果がリアルタイムで棒グラフなどに反映されていく様子を見せます。
  2. オンラインクイズの進行

    • クイズの司会者が「次の問題です」とボタンを押すと、全参加者の画面に新しい問題がSSE経由で一斉に配信されます。

これらのアイデアの中から、デモの目的や対象者に合わせて最適なものを選んでみてください。SSEの「手軽さ」と「リアルタイム性」を視覚的に分かりやすく表現することが、良いデモを作る上での鍵となります。

(返答終了理由: STOP)


ユーザー: codepenなどでservice workerの利用のデモをすることは可能ですか?

SSEをservice workerでシミュレートしようと思ってます(service workerに拘っては居ません。フロントエンドのみで完結するSSEのデモが作成したいのです)。


AI: はい、非常に良い質問ですね。結論から言うと、CodePen上でService Workerを使ってSSEをシミュレートするのは、非常に難しい、または現実的ではありません。

しかし、ご要望の「フロントエンドのみで完結するSSEのデモ」を作成するための、より優れた代替案がいくつかあります。


なぜCodePenとService Workerの相性が悪いのか

まず、なぜ難しいのかを簡単に説明します。

  1. セキュリティとオリジンの問題: Service Workerは強力な機能のため、HTTPSで提供されるページでしか動作しません。また、スクリプトが提供される「オリジン」(ドメイン)に強く紐づきます。CodePenはプレビューをiframe内で、特殊なオリジン(例: cdpn.io)で実行するため、Service Workerの登録とスコープ(どの範囲のリクエストを制御するか)の管理が非常に複雑になります。
  2. ファイル構造: Service Workerは通常、独立したファイル(例: sw.js)としてサイトのルートに配置する必要があります。CodePenのUIはHTML/CSS/JSの3つのパネルに分かれており、このようなファイルベースの構造を直感的に作ることができません。

これらの理由から、Service Workerのデモ自体がCodePen上では困難であり、その上でSSEのシミュレーションという応用的なことを行うのは、さらに難易度が高くなります。


【本題】フロントエンドのみで完結するSSEデモのおすすめの方法

Service Workerに固執せず、「フロントエンド完結」という目的を達成するための、よりシンプルで効果的な方法を提案します。

おすすめの方法1:EventSourceを模倣(モック)したクラスを作成する

これが最も手軽で、CodePen上で完結させるのに最適な方法です。本物のEventSource APIを使わずに、同じような振る舞いをするJavaScriptクラスを自作します。

デモのアイデア:

  • 擬似的なサーバープッシュ: setIntervalsetTimeoutを使い、クラスの内部で定期的にイベントを発生させます。
  • 同じインターフェース: 本物のEventSourceと同じように、addEventListener('message', ...)addEventListener('customEvent', ...)といったメソッドを持たせます。
  • イベントオブジェクトの再現: イベントリスナーに渡すオブジェクトも、本物と同じように{ data: '...' }という形式にします。

この方法の利点:

  • 完全フロントエンド完結: 外部ライブラリもサーバーも不要。CodePenのJSパネルだけで完結します。
  • シンプルで分かりやすい: SSEの「クライアント側がどうイベントを受け取るか」という部分に焦点を当てて、ロジックをシンプルに説明できます。
  • 確実な動作: Service Workerのような環境依存の問題が一切なく、どのブラウザでも安定して動作します。

デモシナリオの例:

  1. 「サーバーに接続」ボタンを押すと、自作のMockEventSourceクラスのインスタンスを生成。
  2. クラス内部でsetIntervalが1秒ごとに動き出す。
  3. タイマーが発火するたびに、「新しいニュースです: ...」や「現在の株価: ...」のようなランダムなメッセージを生成。
  4. addEventListenerで登録しておいた関数を呼び出し、画面のリストにメッセージを追加していく。
  5. 「切断」ボタンでcloseメソッドを呼び出し、setIntervalを停止させる。

おすすめの方法2:StackBlitzやCodeSandboxを利用する

もし「どうしても本物のService Workerを使ってみたい」ということであれば、CodePenの代わりにStackBlitzCodeSandboxを利用することをお勧めします。

これらのサービスは、CodePenよりも高機能なオンラインIDEであり、以下のような利点があります。

  • 本物のファイルシステム: index.htmlsw.jsといったファイルを個別に作成・編集できます。
  • 仮想開発サーバー: バックグラウンドでNode.jsベースのサーバーが動いており、Service Workerの登録や動作がローカル開発環境に非常に近いです。
  • より忠実なデモ: Service Workerがリクエストを傍受(intercept)し、ストリーミングレスポンスを返すという、より現実に近いSSEのシミュレーションが可能です。

この方法でのデモシナリオ:

  1. クライアント側のJS(main.js)でnew EventSource('/sse-endpoint')を実行。
  2. Service Worker(sw.js)でfetchイベントをリッスンし、/sse-endpointへのリクエストを傍受する。
  3. 傍受したリクエストに対し、ReadableStreamを使ってレスポンスを生成する。
  4. そのストリームに対して、setIntervalなどで定期的にdata: ...\n\nという形式の文字列を書き込んでいく。

まとめ

あなたの目的に合わせて、以下のように選ぶのが良いでしょう。

目的 おすすめの方法 特徴
手軽さフロントエンド完結を最優先したい
(CodePenで作りたい)
方法1:EventSourceの模倣クラス 最も簡単で確実。SSEのクライアント側の挙動をデモするのに最適。
Service Workerの動作を忠実にデモしたい
(本物の技術を使いたい)
方法2:StackBlitzやCodeSandbox 現実の環境に近い形で、Service WorkerによるSSEシミュレーションが可能。

まずは「方法1」でSSEのコンセプトを視覚的に表現するデモを作成し、もしService Worker自体の挙動に興味が湧いたら「方法2」に挑戦してみるのが良いステップだと思います。

(返答終了理由: STOP)


ユーザー: ではwasmとpostMessageで外部サーバーをシミュレートする方法はないですか?


AI: はい、非常に面白いアイデアです! 結論から言うと、はい、WASMとpostMessage(特にWeb Workerと組み合わせて)で外部サーバーをシミュレートすることは技術的に可能です。

ただし、それがSSEのデモとして最適かどうかは、デモの目的によって変わります。このアプローチの長所と短所、そしてより良い代替案について詳しく解説します。


WASM + Web Worker + postMessageでサーバーをシミュレートする方法

これは、ブラウザ内で「サーバー役」と「クライアント役」を完全に分離する高度なシミュレーションです。

  1. Web Worker(サーバー役の環境):

    • まず、Web Worker(例: server-worker.js)を作成します。Web WorkerはメインのUIスレッドとは独立して動くため、重い処理を行っても画面が固まりません。これは、サーバーが別プロセスで動いている様子を擬似的に再現するのに最適です。
  2. WASMモジュール(サーバー役のロジック):

    • C++、Rust、Goなどの言語で、データを定期的に生成するロジックを記述します。(例: 1秒ごとにカウンターをインクリメントしてその値を返す、など)
    • これをWASMにコンパイルします。
    • Web Workerの中で、このWASMモジュールを読み込み、インスタンス化します。
  3. postMessage(通信手段):

    • Web Worker内でsetIntervalなどを使い、定期的にWASMモジュールの関数を呼び出してデータを生成させます。
    • 生成されたデータを、self.postMessage(data)を使ってメインスレッド(クライアント役)に送信します。
  4. メインスレッド(クライアント役):

    • メインのJavaScriptコードで、new Worker('server-worker.js')としてWorkerを起動します。
    • worker.addEventListener('message', event => { ... }) を使って、Workerから送られてくるデータ(サーバーからのプッシュ通知の代わり)を受け取ります。
    • 受け取ったデータを使ってDOMを更新します。(例: リアルタイムチャートを更新する、リストに新しい項目を追加する、など)

このアプローチの評価

長所(メリット)

  • 完全な非同期性の再現: Web Workerを使うことで、メインUIスレッドを全くブロックせずにデータソースを動かせます。これは、ネットワーク通信が非同期であるという実世界の状況を非常に忠実に再現します。
  • 複雑なロジックのシミュレーション: もしデモのデータ生成ロジックが非常に複雑な場合(例: 物理シミュレーションの結果、ゲームの盤面の更新、リアルタイムなデータ分析など)、計算性能の高いWASMで実装するのは非常に有効です。
  • 技術的な面白さ: WASM、Web Worker、postMessageというモダンなWeb技術を組み合わせた、非常に高度で興味深いデモになります。

短所(デメリット)と注意点

  • 過剰な複雑さ(Overkill): 単純な「1秒ごとに時刻を送る」といったSSEのデモには、WASMは明らかに過剰スペックです。WASMのコンパイルやJSとの連携(グルーコード)の準備が必要になり、SSEの本質を見せるという目的から焦点がずれてしまう可能性があります。
  • SSEのAPIを直接使っていない: この方法では、クライアント側はnew EventSource()を使いません。代わりにworker.addEventListener('message', ...)を使います。つまり、SSEの「結果」はシミュレートできますが、SSEの「使い方(API)」そのもののデモにはなりません。 これはデモの目的を考える上で非常に重要な点です。
  • ビルド環境が必要: WASMを使うには、通常C++やRustのコンパイラと関連ツール(Emscriptenやwasm-packなど)が必要です。CodePenのような手軽な環境でサッと作るのには向いていません。(StackBlitzなど、より高機能な環境が必要になります)

【推奨】より目的に沿った代替案

あなたの「フロントエンドのみで完結させたい」という要望と、「SSEのデモ」という目的を考えると、よりバランスの取れた素晴らしい方法があります。

提案:Web Worker + 「EventSourceアダプター」

これは、WASMの複雑さを排除し、かつSSEのAPIを忠実に再現するエレガントな方法です。

ステップ:

  1. サーバー役のWorker (JavaScriptのみ)

    • Web Worker (sse-worker.js) を作成します。
    • WASMは使わず、素のJavaScriptでsetIntervalを使って定期的にデータを生成し、self.postMessage()で送信します。これで十分です。
    // sse-worker.js
    let count = 0;
    setInterval(() => {
      self.postMessage({ data: `サーバーからのメッセージ ${count++}` });
    }, 1000);
  2. クライアント側に「アダプタークラス」を作成

    • ここがポイントです。メインスレッドに、本物のEventSourceと全く同じインターフェースを持つクラスを作成します。
    // main.js
    class WorkerEventSource {
      constructor(workerPath) {
        this.worker = new Worker(workerPath);
        this.listeners = {};
    
        this.worker.onmessage = (event) => {
          // 'message' という名前のイベントを模倣してディスパッチ
          if (this.listeners['message']) {
            this.listeners['message'].forEach(callback => callback(event));
          }
        };
      }
    
      addEventListener(eventName, callback) {
        if (!this.listeners[eventName]) {
          this.listeners[eventName] = [];
        }
        this.listeners[eventName].push(callback);
      }
    
      close() {
        this.worker.terminate();
      }
    }
  3. デモのアプリケーションコード

    • デモのコードでは、この自作クラスを本物のEventSourceと全く同じように使います。
    // main.js (続き)
    const sseSource = new WorkerEventSource('sse-worker.js');
    
    sseSource.addEventListener('message', (e) => {
      console.log('受信:', e.data);
      // ここでDOMを更新する
      const li = document.createElement('li');
      li.textContent = e.data.data; // workerからのオブジェクトを取得
      document.getElementById('events').appendChild(li);
    });
    
    // 10秒後に接続を閉じるデモ
    setTimeout(() => {
      sseSource.close();
      console.log('SSE接続を閉じました。');
    }, 10000);

まとめ

アプローチ 手軽さ SSEの再現度 技術的複雑さ おすすめの状況
WASM + Worker △ (結果のみ) データ生成ロジックが非常に重く、WASMの利用自体がデモの目的である場合。
Worker + アダプター ◎ (APIも再現) 今回最もおすすめ。 フロントエンド完結で、SSEのAPIの使い方を忠実に、かつ明確にデモできる。
JS setInterval のみ
(前回の回答)
〇 (APIを模倣) 最も手軽にSSEの概念を視覚化したい場合。Web Workerのセットアップも不要。

結論として、WASMを使うアイデアは技術的に可能で非常に面白いですが、SSEのデモとしては複雑すぎることが多いです。代わりに「Web Worker + EventSourceアダプター」方式を採用すると、フロントエンドだけで完結しつつ、SSEのAPIの使い方まで含めた、非常に高品質で教育的なデモを作成できます。

(返答終了理由: STOP)


ユーザー: いいですね。web workerとpostMessageはcodepen上で利用可能ですか?


AI: はい、素晴らしい質問です!

結論から言うと、はい、Web WorkerとpostMessageはCodePen上で利用可能です。

ただし、CodePenのファイル構造(HTML/CSS/JSの3パネル)の特性上、ローカルで開発するときのように別ファイル(例: worker.js)を作成して読み込むことはできません。

その代わりに、JavaScriptのコードを文字列として扱い、それをBlobURL.createObjectURL()を使ってその場で「ファイル化」するという、非常にエレガントなテクニックを使います。


CodePenでWeb Workerを使うための具体的な方法

この方法は、自己完結型のデモを作成するのに最適です。

ステップ1: WorkerのコードをJavaScriptの文字列として定義する

まず、Worker内部で実行したい処理を、テンプレートリテラル(``)を使って文字列として記述します。

const workerCode = `
  // --- ここから下がWorkerの内部で動くコード ---

  let count = 0;
  
  // 1秒ごとにメッセージを送信する (SSEのシミュレーション)
  setInterval(() => {
    // postMessageを使ってメインスレッドにデータを送る
    self.postMessage({ data: \`サーバーからのメッセージ \${count++}\` });
  }, 1000);

  // --- ここまでがWorkerのコード ---
`;

ポイントは、このコード全体がworkerCodeという一つの変数(文字列)に格納されていることです。

ステップ2: 文字列からBlobオブジェクトを作成する

次に、この文字列をBlobオブジェクトに変換します。Blobは、ファイルのようなデータのかたまりを表現するオブジェクトです。MIMEタイプを'application/javascript'に指定します。

const blob = new Blob([workerCode], { type: 'application/javascript' });

ステップ3: Blobから一時的なURLを生成する

URL.createObjectURL()メソッドを使って、ブラウザのメモリ上にあるBlobを指し示す、一時的なURLを生成します。このURLが、あたかも外部ファイル(worker.js)へのパスのように振る舞います。

const workerURL = URL.createObjectURL(blob);

ステップ4: 生成したURLを使ってWorkerを起動する

最後に、生成したURLをnew Worker()の引数に渡して、Workerを通常通り起動します。

const worker = new Worker(workerURL);

すべてを組み合わせたCodePen用のサンプルコード

以下は、CodePenのJSパネルにそのまま貼り付けて動作させることができる、完全なサンプルです。HTMLパネルには<ul id="events"></ul>とだけ記述してください。

HTML:

<h2>SSEシミュレーション (Web Worker)</h2>
<p>Workerから1秒ごとにメッセージが届きます。</p>
<ul id="events"></ul>

JavaScript:

// --- ステップ1: Workerのコードを文字列として定義 ---
const workerCode = `
  let count = 0;
  
  // 1秒ごとにメッセージを送信
  setInterval(() => {
    self.postMessage({ message: \`Workerからのメッセージ No.\${count++}\` });
  }, 1000);
`;

// --- ステップ2: 文字列からBlobを作成 ---
const blob = new Blob([workerCode], { type: 'application/javascript' });

// --- ステップ3: Blobから一時的なURLを生成 ---
const workerURL = URL.createObjectURL(blob);

// --- ステップ4: 生成したURLでWorkerを起動 ---
console.log('Workerを起動します...');
const worker = new Worker(workerURL);

// --- メインスレッド側でWorkerからのメッセージを受け取る ---
const eventList = document.getElementById('events');

worker.addEventListener('message', (event) => {
  console.log('メインスレッドが受信:', event.data);
  
  const newLi = document.createElement('li');
  newLi.textContent = event.data.message; // Workerから送られたメッセージ
  eventList.appendChild(newLi);
});

// デモ用に10秒後にWorkerを停止
setTimeout(() => {
  worker.terminate(); // Workerを停止
  console.log('Workerを停止しました。');
  const newLi = document.createElement('li');
  newLi.textContent = '--- 接続終了 ---';
  newLi.style.color = 'red';
  eventList.appendChild(newLi);
}, 10000);

まとめ

  • CodePenでWeb Workerは使えますか?
    • はい、使えます。
  • どうやって?
    • Workerのコードを文字列で用意し、BlobURL.createObjectURL()を使って動的にURLを生成して読み込みます。
  • メリットは?
    • 自己完結: HTML/CSS/JSの各パネルだけで完結し、外部ファイルが不要なため、デモの共有が非常に簡単です。
    • 環境構築不要: CodePenを開くだけで、すぐに試せます。

このテクニックを使えば、あなたが考えている「Web Workerを使ったフロントエンド完結型のSSEデモ」をCodePen上で完璧に実現できます。ぜひ試してみてください!

(返答終了理由: STOP)


ユーザー: それではそのデモ用のhtmlを生成してください。index.htmlにやってることの説明を書いてください。スマホで開くことを考えてます。デモはLLMのthinkingとアウトプットの遅延をシミュレートしたものにしてください。


AI: はい、承知いたしました。 Web WorkerとpostMessageを利用して、LLM(大規模言語モデル)の思考と応答の遅延をシミュレートするデモ用のHTMLファイルを生成します。

このHTMLファイルは単体で完結しており、スマートフォンでの表示にも配慮したレスポンシブデザインになっています。ファイルに保存してブラウザで開くか、そのままCodePenのHTMLパネルに貼り付けても動作します。


index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>LLM応答シミュレーションデモ</title>
    <style>
        /* 基本スタイルとレスポンシブ対応 */
        :root {
            --bg-color: #f4f7f9;
            --font-color: #333;
            --primary-color: #007bff;
            --card-bg: #ffffff;
            --output-bg: #2d2d2d;
            --output-color: #e0e0e0;
            --border-color: #e0e0e0;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            margin: 0;
            padding: 1rem;
            background-color: var(--bg-color);
            color: var(--font-color);
            line-height: 1.6;
        }
        .container {
            max-width: 800px;
            margin: 0 auto;
            padding: 1rem;
            background-color: var(--card-bg);
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.08);
        }
        h1, h2 {
            border-bottom: 2px solid var(--primary-color);
            padding-bottom: 0.5rem;
            margin-top: 1.5rem;
        }
        p, li {
            font-size: 1rem;
        }
        code {
            background-color: #e9ecef;
            padding: 0.2em 0.4em;
            border-radius: 3px;
            font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
        }
        ul {
            padding-left: 1.5rem;
        }
        
        /* デモUIのスタイル */
        .demo-area {
            margin-top: 2rem;
        }
        .generate-button {
            display: block;
            width: 100%;
            padding: 0.8rem 1rem;
            font-size: 1.1rem;
            font-weight: bold;
            color: #fff;
            background-color: var(--primary-color);
            border: none;
            border-radius: 6px;
            cursor: pointer;
            transition: background-color 0.2s, transform 0.1s;
        }
        .generate-button:hover:not(:disabled) {
            background-color: #0056b3;
        }
        .generate-button:active:not(:disabled) {
            transform: scale(0.98);
        }
        .generate-button:disabled {
            background-color: #999;
            cursor: not-allowed;
        }
        .status {
            margin-top: 1rem;
            font-weight: bold;
            text-align: center;
            height: 1.5rem; /* 高さを固定してレイアウトが崩れないようにする */
        }
        .output-container {
            margin-top: 1rem;
            background-color: var(--output-bg);
            color: var(--output-color);
            border-radius: 6px;
            padding: 1rem;
            min-height: 150px;
            font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
            white-space: pre-wrap; /* テキストの折り返しを有効にする */
            word-wrap: break-word;
        }
        /* タイピングカーソルのアニメーション */
        .cursor {
            display: inline-block;
            width: 10px;
            height: 1.2em;
            background-color: var(--output-color);
            animation: blink 1s step-end infinite;
            vertical-align: bottom;
        }
        @keyframes blink {
            from, to { background-color: transparent; }
            50% { background-color: var(--output-color); }
        }
    </style>
</head>
<body>
    <main class="container">
        <h1>LLM応答シミュレーションデモ</h1>

        <h2>このデモについて</h2>
        <p>
            このページは、ChatGPTのような大規模言語モデル(LLM)が、まるでリアルタイムで文章をタイピングしているかのように応答を生成する様子をシミュレートしたものです。
        </p>
        <p>
            このデモは、サーバーを使わず、<strong>すべてブラウザの中だけで完結</strong>しています。
        </p>
        
        <h3>技術的な仕組み</h3>
        <ul>
            <li>
                <strong>Web Worker:</strong> LLMの役割を担う「サーバー」を擬似的に再現するために使用しています。Web Workerはメインの処理とは別のスレッドで動くため、重い処理を行ってもページの操作が固まることはありません。
            </li>
            <li>
                <strong>postMessage:</strong> メインページ(クライアント役)とWeb Worker(サーバー役)が通信するための仕組みです。Workerは生成したテキストの断片を<code>postMessage</code>で少しずつメインページに送信します。
            </li>
            <li>
                <strong>Blob と URL.createObjectURL:</strong> CodePenのような単一ファイル環境でWeb Workerを動かすためのテクニックです。Worker用のJavaScriptコードを文字列として定義し、その場で一時的な「ファイル」としてURLを生成して読み込んでいます。
            </li>
            <li>
                <strong>SSE (Server-Sent Events) のシミュレーション:</strong> 本来サーバーからクライアントへ一方向にデータを送り続けるSSEの挙動を、Web Workerからの<code>postMessage</code>で擬似的に再現しています。
            </li>
        </ul>

        <div class="demo-area">
            <h2>デモ</h2>
            <button id="generateBtn" class="generate-button">文章を生成する</button>
            <div id="status" class="status"></div>
            <div class="output-container">
                <span id="output"></span><span id="cursor" class="cursor" style="display: none;"></span>
            </div>
        </div>
    </main>

    <script>
        // --- DOM要素の取得 ---
        const generateBtn = document.getElementById('generateBtn');
        const outputSpan = document.getElementById('output');
        const statusDiv = document.getElementById('status');
        const cursorSpan = document.getElementById('cursor');

        // --- 1. Web Workerのコードを文字列として定義 ---
        // このコードがバックグラウンドで実行される
        const workerCode = `
            // LLMが生成するサンプルテキスト
            const sampleText = "Web Workerは、メインのUIスレッドとは独立したバックグラウンドスレッドでスクリプトを実行するためのシンプルな手段を提供します。これにより、UIを固まらせることなく、時間のかかる処理を実行できます。このデモでは、Web WorkerがLLMの役割を担い、postMessageを介して一文字ずつテキストを送信することで、リアルタイムのストリーミング出力をシミュレートしています。";

            // メインスレッドからのメッセージを受け取るリスナー
            self.onmessage = function(event) {
                if (event.data.command === 'GENERATE') {
                    generateTextStream();
                }
            };

            function generateTextStream() {
                let currentIndex = 0;
                
                // 最初に「思考中」をシミュレートするための遅延
                self.postMessage({ type: 'STATUS', payload: '思考中...' });
                const thinkingTime = 1000 + Math.random() * 1000; // 1〜2秒のランダムな思考時間

                setTimeout(() => {
                    self.postMessage({ type: 'STATUS', payload: '生成中...' });
                    
                    // テキストを1文字ずつ送信するインターバル処理
                    const intervalId = setInterval(() => {
                        if (currentIndex < sampleText.length) {
                            const char = sampleText[currentIndex];
                            // メインスレッドに文字データを送信
                            self.postMessage({ type: 'CHUNK', payload: char });
                            currentIndex++;
                        } else {
                            // すべての文字を送信し終わったら完了メッセージを送信
                            clearInterval(intervalId);
                            self.postMessage({ type: 'DONE' });
                            self.postMessage({ type: 'STATUS', payload: '完了!' });
                        }
                    }, 50 + Math.random() * 100); // 50ms〜150msのランダムなタイピング速度
                }, thinkingTime);
            }
        `;

        // --- 2. 文字列からBlobとURLを生成 ---
        const blob = new Blob([workerCode], { type: 'application/javascript' });
        const workerURL = URL.createObjectURL(blob);

        // --- 3. Workerを起動 ---
        const worker = new Worker(workerURL);

        // --- 4. ボタンのクリックイベントリスナー ---
        generateBtn.addEventListener('click', () => {
            // UIを初期化
            outputSpan.textContent = '';
            statusDiv.textContent = '';
            generateBtn.disabled = true; // ボタンを無効化
            cursorSpan.style.display = 'inline-block'; // カーソルを表示

            // Workerに生成開始のコマンドを送信
            worker.postMessage({ command: 'GENERATE' });
        });
        
        // --- 5. Workerからのメッセージを受け取るリスナー ---
        worker.addEventListener('message', (event) => {
            const { type, payload } = event.data;

            switch(type) {
                case 'STATUS':
                    // ステータス表示を更新
                    statusDiv.textContent = payload;
                    break;
                
                case 'CHUNK':
                    // 受け取った文字を出力エリアに追記
                    outputSpan.textContent += payload;
                    break;
                
                case 'DONE':
                    // 生成が完了したらボタンを再度有効化
                    generateBtn.disabled = false;
                    cursorSpan.style.display = 'none'; // カーソルを非表示
                    break;
            }
        });

    </script>
</body>
</html>
@podhmo
Copy link
Author

podhmo commented Nov 15, 2025

はい、承知いたしました。
より本気でSSE (Server-Sent Events) をシミュレートするために、本物のEventSource APIとほぼ同じインターフェースを持つクラスを実装します。

これにより、デモのアプリケーションコードは、まるで実際のSSEサーバーと通信しているかのように記述できます。Web WorkerがSSEサーバーの役割を、自作のMockEventSourceクラスがブラウザのEventSource APIの役割を担います。

生成するHTMLは、前回同様に単一ファイルで完結し、スマートフォンでの閲覧にも最適化されています。


index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>高度なSSEシミュレーションデモ</title>
    <style>
        :root {
            --bg-color: #f4f7f9; --font-color: #333; --primary-color: #007bff;
            --secondary-color: #dc3545; --card-bg: #ffffff; --output-bg: #2d2d2d;
            --output-color: #e0e0e0; --border-color: #e0e0e0;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            margin: 0; padding: 1rem; background-color: var(--bg-color); color: var(--font-color); line-height: 1.6;
        }
        .container {
            max-width: 800px; margin: 0 auto; padding: 1rem; background-color: var(--card-bg);
            border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.08);
        }
        h1, h2 { border-bottom: 2px solid var(--primary-color); padding-bottom: 0.5rem; margin-top: 1.5rem; }
        p, li { font-size: 1rem; }
        code { background-color: #e9ecef; padding: 0.2em 0.4em; border-radius: 3px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; }
        .demo-area { margin-top: 2rem; }
        .button-group { display: flex; gap: 0.5rem; }
        .button {
            flex: 1; padding: 0.8rem 1rem; font-size: 1.1rem; font-weight: bold; color: #fff;
            border: none; border-radius: 6px; cursor: pointer; transition: background-color 0.2s, transform 0.1s;
        }
        .button-primary { background-color: var(--primary-color); }
        .button-primary:hover:not(:disabled) { background-color: #0056b3; }
        .button-secondary { background-color: var(--secondary-color); }
        .button-secondary:hover:not(:disabled) { background-color: #b21f2d; }
        .button:active:not(:disabled) { transform: scale(0.98); }
        .button:disabled { background-color: #999; cursor: not-allowed; }
        .status { margin-top: 1rem; font-weight: bold; text-align: center; height: 1.5rem; }
        .output-container {
            margin-top: 1rem; background-color: var(--output-bg); color: var(--output-color); border-radius: 6px;
            padding: 1rem; min-height: 150px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
            white-space: pre-wrap; word-wrap: break-word;
        }
        .cursor { display: inline-block; width: 10px; height: 1.2em; background-color: var(--output-color); animation: blink 1s step-end infinite; vertical-align: bottom; }
        @keyframes blink { from, to { background-color: transparent; } 50% { background-color: var(--output-color); } }
    </style>
</head>
<body>
    <main class="container">
        <h1>高度なSSEシミュレーションデモ</h1>
        <h2>このデモについて</h2>
        <p>
            このデモは、LLM(大規模言語モデル)の応答生成を、<strong>より本格的なSSE(Server-Sent Events)のシミュレーション</strong>で再現したものです。
        </p>
        <p>
            特長は、ブラウザ標準の<code>EventSource</code> APIと全く同じように使える自作クラス <code>MockEventSource</code> を実装した点です。これにより、アプリケーション側のコードは、まるで本物のSSEサーバーと通信しているかのような、自然な記述になっています。
        </p>
        <h3>技術的な仕組み</h3>
        <ul>
            <li>
                <strong>Web Worker (サーバー役):</strong> LLMの思考とテキスト生成を行うバックエンドの役割を担います。SSEのプロトコルに倣い、<code>{ event: 'イベント名', data: 'データ' }</code>という形式で<code>postMessage</code>を送信します。
            </li>
            <li>
                <strong><code>MockEventSource</code> (クライアントAPI役):</strong>
                Web Workerを内部で起動し、<code>postMessage</code>で送られてくるデータを解釈します。そして、本物の<code>EventSource</code>と同様に<code>open</code>, <code>message</code>やカスタムイベントを発行します。<code>addEventListener()</code><code>close()</code>といったメソッドも完全に互換性があります。
            </li>
        </ul>
        <p>
            これにより、サーバーなしのフロントエンド環境だけで、SSEのデータ受信フローを忠実に再現しています。
        </p>

        <div class="demo-area">
            <h2>デモ</h2>
            <div class="button-group">
                <button id="generateBtn" class="button button-primary">文章を生成する</button>
                <button id="stopBtn" class="button button-secondary" disabled>中断する</button>
            </div>
            <div id="status" class="status"></div>
            <div class="output-container">
                <span id="output"></span><span id="cursor" class="cursor" style="display: none;"></span>
            </div>
        </div>
    </main>

    <script>
        // ==================================================================
        // 1. SSEをシミュレートする MockEventSource クラス
        // ==================================================================
        class MockEventSource {
            static CONNECTING = 0;
            static OPEN = 1;
            static CLOSED = 2;

            constructor(workerCodeString) {
                this.readyState = MockEventSource.CONNECTING;
                this._listeners = {};

                // onopen, onmessage, onerror プロパティもサポート
                this.onopen = null;
                this.onmessage = null;
                this.onerror = null;

                const blob = new Blob([workerCodeString], { type: 'application/javascript' });
                const workerURL = URL.createObjectURL(blob);
                this._worker = new Worker(workerURL);
                
                this._worker.onmessage = this._handleWorkerMessage.bind(this);
                this._worker.onerror = this._handleWorkerError.bind(this);
            }

            _handleWorkerMessage(event) {
                const sseData = event.data;

                // Workerからの準備完了通知で'open'イベントを発行
                if (sseData.event === 'system' && sseData.data === 'WORKER_READY') {
                    if (this.readyState === MockEventSource.CONNECTING) {
                        this.readyState = MockEventSource.OPEN;
                        const openEvent = new Event('open');
                        this._dispatchEvent(openEvent);
                        if (this.onopen) this.onopen(openEvent);
                    }
                    return;
                }
                
                // SSE形式のメッセージを解釈してイベントを発行
                const eventName = sseData.event || 'message';
                const messageEvent = new MessageEvent(eventName, {
                    data: sseData.data,
                    lastEventId: sseData.id || ''
                });

                this._dispatchEvent(messageEvent);

                if (eventName === 'message' && this.onmessage) {
                    this.onmessage(messageEvent);
                }
            }
            
            _handleWorkerError(error) {
                this.readyState = MockEventSource.CLOSED;
                const errorEvent = new Event('error');
                this._dispatchEvent(errorEvent);
                if (this.onerror) this.onerror(errorEvent);
                console.error("Worker error:", error);
            }

            _dispatchEvent(event) {
                const listeners = this._listeners[event.type] || [];
                listeners.forEach(callback => callback(event));
            }

            addEventListener(eventName, callback) {
                if (!this._listeners[eventName]) {
                    this._listeners[eventName] = [];
                }
                this._listeners[eventName].push(callback);
            }
            
            close() {
                if (this.readyState !== MockEventSource.CLOSED) {
                    this._worker.terminate();
                    this.readyState = MockEventSource.CLOSED;
                    console.log("MockEventSource connection closed.");
                }
            }
        }

        // ==================================================================
        // 2. Web Workerのコード (SSEサーバー役)
        // ==================================================================
        const workerCode = `
            const sampleText = "このデモは、本物のEventSource APIを忠実に模倣しています。Web Workerがサーバーとして振る舞い、'thinking'や'done'といったカスタムイベントも送信できます。これにより、クライアント側のコードは非常に直感的になります。";

            // SSE形式のイベントを送信するヘルパー関数
            function postEvent(eventName, data, id = null) {
                self.postMessage({ event: eventName, data: data, id: id });
            }

            // LLMのシミュレーションを実行する関数
            function runLLMSimulation() {
                postEvent('thinking', '思考中...');
                
                const thinkingTime = 1000 + Math.random() * 500; // 1〜1.5秒の思考時間
                
                setTimeout(() => {
                    let currentIndex = 0;
                    const streamInterval = setInterval(() => {
                        if (currentIndex < sampleText.length) {
                            const chunkSize = 1 + Math.floor(Math.random() * 4); // 1〜4文字のチャンク
                            const chunk = sampleText.substring(currentIndex, currentIndex + chunkSize);
                            currentIndex += chunk.length;
                            postEvent('message', chunk, currentIndex); // messageイベントでトークンを送信
                        } else {
                            clearInterval(streamInterval);
                            postEvent('done', '生成が完了しました!'); // 完了を通知
                        }
                    }, 60 + Math.random() * 60); // 60〜120msのランダムなタイピング速度
                }, thinkingTime);
            }

            // メインスレッドからのコマンドを待つ
            self.onmessage = (e) => {
                if (e.data.command === 'START') {
                    runLLMSimulation();
                }
            };
            
            // 準備完了をメインスレッドに通知
            postEvent('system', 'WORKER_READY');
        `;


        // ==================================================================
        // 3. アプリケーションコード (クライアント役)
        // ==================================================================
        const generateBtn = document.getElementById('generateBtn');
        const stopBtn = document.getElementById('stopBtn');
        const outputSpan = document.getElementById('output');
        const statusDiv = document.getElementById('status');
        const cursorSpan = document.getElementById('cursor');

        let sseSource = null;

        function startGeneration() {
            // UIを初期化・更新
            generateBtn.disabled = true;
            stopBtn.disabled = false;
            outputSpan.textContent = '';
            statusDiv.textContent = '接続中...';
            cursorSpan.style.display = 'inline-block';
            
            // --- ここがポイント!本物のEventSourceのように使える ---
            sseSource = new MockEventSource(workerCode);

            sseSource.addEventListener('open', (e) => {
                console.log('接続が開かれました', e);
                statusDiv.textContent = '接続完了。生成を開始します。';
                // Workerに生成開始コマンドを送信
                sseSource._worker.postMessage({ command: 'START' });
            });

            sseSource.addEventListener('thinking', (e) => {
                console.log('カスタムイベント[thinking]受信:', e.data);
                statusDiv.textContent = e.data;
            });
            
            // onmessage プロパティも利用可能
            sseSource.onmessage = (e) => {
                // console.log('メッセージ受信:', e.data);
                outputSpan.textContent += e.data;
                statusDiv.textContent = '生成中...';
            };

            sseSource.addEventListener('done', (e) => {
                console.log('カスタムイベント[done]受信:', e.data);
                statusDiv.textContent = e.data;
                stopGeneration(); // 完了したら接続を閉じる
            });

            sseSource.addEventListener('error', (e) => {
                console.error('エラーが発生しました', e);
                statusDiv.textContent = 'エラーが発生しました。';
                stopGeneration();
            });
        }
        
        function stopGeneration() {
            if (sseSource) {
                sseSource.close();
                sseSource = null;
            }
            generateBtn.disabled = false;
            stopBtn.disabled = true;
            cursorSpan.style.display = 'none';
            if (statusDiv.textContent.includes('中')) {
                statusDiv.textContent = '中断しました。';
            }
        }
        
        generateBtn.addEventListener('click', startGeneration);
        stopBtn.addEventListener('click', stopGeneration);
    </script>
</body>
</html>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment