0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Node.js標準のhttpモジュールだけでSSEダッシュボードを実装する

0
Last updated at Posted at 2026-08-07

対象読者と動作環境

  • Node.js の標準 http モジュールだけで Server-Sent Events (SSE) を実装したい方
  • Express や ws のようなライブラリなしで、簡易的なリアルタイムダッシュボードを組みたい方
  • 動作確認環境: Node.js v24.14.0(package.jsonengines>=24.0.0)/ TypeScript ^5.8.3(devDependency)

題材は、将棋の駒をモチーフにしたローカル LLM 専用マルチエージェント CLI「Jin」のダッシュボード機能です。Jin 自体の紹介はこの記事では扱いません。「なぜ動くか」ではなく「どう実装したか」だけをコードで追います。Jin 全体の紹介は末尾のリンクを参照してください。

Jin のランタイム依存パッケージは @clack/prompts ^0.9.1 と chalk ^5.4.1 の2つだけです。ダッシュボードのフロントエンドが読み込む Chart.js は CDN 経由で、npm の依存関係には含まれていません。ここで紹介するコードは全て src/dashboard/ 以下にあります。

src/dashboard/
├── server.ts       # SSE / REST の口を持つ http サーバー
├── eventBus.ts      # EventEmitter を薄くラップしたPub/Sub
├── template.ts      # HTML全体の組み立てとフィット/マーキーのブラウザ側JS
└── html/
    ├── layout.ts           # DOM構造
    ├── chartsScript.ts     # Chart.js連携
    ├── conversationScript.ts # 会話ログ表示
    ├── boardScript.ts       # 布陣盤(SVG)の描画
    └── styles.ts            # CSS(TypeScript文字列)

SSEサーバの実装

SSE 自体はプロトコルとしては単純です。レスポンスヘッダで Content-Type: text/event-stream を返し、data: ...\n\n の形式でテキストを書き続けるだけで成立します。専用ライブラリがなくても http.createServer のハンドラ内で完結します。

// src/dashboard/server.ts:27-43
if (url === '/api/events') {
  res.writeHead(200, {
    'Content-Type':  'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection':    'keep-alive',
  });

  // 接続確認用の初期pingを送る
  res.write('event: ping\ndata: {}\n\n');

  const unsubscribe = subscribe((event) => {
    res.write(`data: ${JSON.stringify(event)}\n\n`);
  });

  req.on('close', unsubscribe);
  return;
}

ポイントは2つです。

res.end() を呼ばない。 通常のリクエストハンドラは最後に res.end() でレスポンスを終端しますが、SSE ではそれをしません。res.write() を呼んだ後もコネクションは開いたままになり、eventBus.tsbroadcast() が呼ばれるたびに同じ res へ追記していきます。クライアント側の EventSource はこの接続が閉じない限り待ち受け続けます。

接続直後に ping イベントを送る。 ヘッダを書いただけではブラウザ側で接続が確立したかどうか確認しづらいため、data: {} を即座に1件流してハンドシェイクを兼ねています。実際にこのエンドポイントへ curl すると次のように返ってきます(Cache-ControlConnection ヘッダを付けた状態で接続を張った直後の出力です)。

event: ping
data: {}

もう一つ重要なのが req.on('close', unsubscribe) です。ブラウザがタブを閉じる、リロードする、別ページへ遷移するといった理由で EventSource が切断されると、Node 側では reqclose イベントが発火します。ここで subscribe() が返した解除関数を呼ばないと、切断済みのソケットに向けて res.write() を呼び続けるコールバックが eventBusEventEmitter に残り続け、メモリリークになります。実装した時点で愚直に「イベントを送るだけ」の分岐にしてしまうと見落としがちな箇所です。

EventEmitterラッパーの薄さ

イベントの発行元(各駒の分析処理など)と、SSE 経由でブラウザへ流す購読側(server.ts)を直接つなぐと結合が強くなるため、間に eventBus.ts を挟んでいます。中身は Node 標準の EventEmitter を関数2つでラップしただけの、21行のモジュールです。

// src/dashboard/eventBus.ts
import { EventEmitter } from 'events';
import type { InteractionEvent } from '../activity/interactionWriter.js';

const emitter = new EventEmitter();
emitter.setMaxListeners(100);

const EVENT_NAME = 'interaction';

/** ダッシュボードクライアントへのブロードキャスト */
export function broadcast(event: InteractionEvent): void {
  emitter.emit(EVENT_NAME, event);
}

/**
 * イベントを購読する。
 * 戻り値の関数を呼ぶと購読を解除する。
 */
export function subscribe(callback: (event: InteractionEvent) => void): () => void {
  emitter.on(EVENT_NAME, callback);
  return () => emitter.off(EVENT_NAME, callback);
}

broadcast()subscribe() の2関数だけを外部に公開し、emitter 自体は外に出しません。イベント名も 'interaction' の1種類しか使っていないため、型の集約点は InteractionEventsrc/activity/interactionWriter.ts)のユニオン型に一本化されています。

emitter.setMaxListeners(100) を入れているのは、Node の EventEmitter がデフォルトで1イベント名につき10個までしかリスナー登録を許容せず、11個目以降を登録すると MaxListenersExceededWarning を標準エラーに出すためです。SSE のコネクション1本につき subscribe() が1つのリスナーを追加するので、ダッシュボードを複数タブで開いたり、EventSource がネットワーク断から自動再接続する際に古いリスナーの解除が一瞬遅れたりすると、10個という既定値はすぐに超えます。実際にリークしているわけではないため、上限だけ引き上げて警告を止めています。

subscribe() の戻り値を「購読解除の関数そのもの」にしている設計もこのモジュールの薄さに効いています。呼び出し側は unsubscribe という名前の変数を1つ持てば済み、server.ts 側では前述の req.on('close', unsubscribe) に渡すだけで解除が完結します。

二重起動対策(EADDRINUSEとserver.unref)

Jin はダッシュボードの起動有無を選ばせず、CLI 起動時に無条件で startDashboard() を呼びます(src/cli.ts:145)。つまり同じマシンで jin を2つのターミナルから起動すると、2つ目のプロセスは1つ目がすでに掴んでいるポートへの listen() に失敗します。この失敗をハンドリングしているのが次の部分です。

// src/dashboard/server.ts:8-10
const PORT = Number(process.env.JIN_DASHBOARD_PORT ?? 3050) || 3050;
const HOST = '127.0.0.1';
// src/dashboard/server.ts:97-103
server.on('error', (err: NodeJS.ErrnoException) => {
  if (err.code === 'EADDRINUSE') openBrowser(`http://localhost:${PORT}`);
});

// ログには要求やLLMプロンプトが含まれるため、外部インターフェースへ公開しない。
server.listen(PORT, HOST, () => { openBrowser(`http://localhost:${PORT}`); });
server.unref();

server.on('error', ...)EADDRINUSE(アドレス使用中)だけを見て、その場合は新しいサーバーを起動する代わりに、既に起動している1つ目のダッシュボードの URL をブラウザで開き直すだけにしています。例外を投げて2つ目の jin プロセスがクラッシュすることはありません。実際に同一ポートで startDashboard() を1つのプロセス内から2回呼び出して確認したところ、2回目は EADDRINUSE を捕捉してエラーにならずに処理が続行しました。

HOST127.0.0.1 に固定しているのは、ダッシュボードが流すログに要求文や LLM への実際のプロンプトが含まれるためです。0.0.0.0 で待ち受けて同一ネットワーク上の別端末からアクセスできる状態にはしたくない、という判断がコードコメントに残っています。

server.unref() は Node.js のドキュメントで次のように説明されている挙動です。

Calling unref() on a server will allow the program to exit if this is the only active server in the event system.

参照: Node.js API - net.md server.unref()

http.Server は内部で net.Server を継承しているため同じ挙動をします。何もしなければ listen() 中のサーバーはイベントループを稼働させ続ける要因になり、他の処理がすべて終わってもプロセスは終了しません。unref() を呼んでおくと、このサーバーの存在が「プロセスを生かし続ける理由」としてカウントされなくなり、他に生きているハンドルがなければプロセスは自然に終了できます。Jin の対話フローは最終的に outro() の後で明示的に process.exit(0) を呼んでいます(src/cli.ts:246)が、unref() はその手前の保険として、ダッシュボード用のサーバーがプロセス終了の妨げにならないようにしています。

ビューポートフィットとマーキーの実装

ダッシュボードの HTML は template.tsbuildHtml() が1つの文字列として組み立てますが、ブラウザ側で走る素の JavaScript も同じファイル内にテンプレートリテラルとして埋め込まれています。その中の fitStage() は、14インチ MacBook Pro の論理解像度(1512×852)を「基準ステージ」として、画面サイズに応じてこのステージ全体を transform: scale() で拡大縮小する処理です。

// src/dashboard/template.ts:36-53
const STAGE_W = 1512;
const STAGE_H = 852;
const FIT_RATIO = 0.9;

function fitStage() {
  const stage = document.getElementById('stage');
  if (!stage) return;
  const scale = Math.max(
    FIT_RATIO,
    Math.min(window.innerWidth * FIT_RATIO / STAGE_W, window.innerHeight * FIT_RATIO / STAGE_H),
  );
  stage.style.transform = 'scale(' + scale + ')';
  // 余白を等分して中央寄せする
  const extraX = window.innerWidth  - STAGE_W * scale;
  const extraY = window.innerHeight - STAGE_H * scale;
  stage.style.marginLeft = (extraX > 0 ? extraX / 2 : 0) + 'px';
  stage.style.marginTop  = (extraY > 0 ? extraY / 2 : 0) + 'px';
}

Math.min(...) で縦横それぞれの「画面の90%に収めるための倍率」を求め、小さい方を採用することでステージ全体が必ず画面内に収まる倍率を出します。Math.max(FIT_RATIO, ...) で下限を 0.9 に固定しているため、1512×852 より小さい画面ではそれ以上は縮小せず、代わりに body 側の overflow: auto (styles.ts:29) でスクロール表示に切り替わります。縮小し続けて文字が読めなくなるより、スクロールさせた方がまだ実用的という判断です。倍率が決まった後は、余った余白 (extraX / extraY) を2で割って margin に充てることで中央寄せしています。

もう1つの setupMarquee() は、駒の報告欄に入るテキストが枠からはみ出したときだけ、テキストを流し読みできるようにする処理です。

// src/dashboard/template.ts:60-72
function setupMarquee() {
  for (const el of document.querySelectorAll('.role-row-desc')) {
    const inner = el.querySelector('.marquee-inner');
    if (!inner) continue;
    if (inner.scrollWidth <= el.clientWidth) continue;   // 収まっていれば流さない

    const text = inner.textContent;
    inner.textContent = text + '   ' + text + '   ';
    const durSec = Math.max(6, inner.scrollWidth / 2 / 25);   // 25px/秒 目安
    el.style.setProperty('--marquee-dur', durSec.toFixed(1) + 's');
    el.classList.add('scrolling');
  }
}

やっていることは3段階です。まず inner.scrollWidth <= el.clientWidth で「そもそもはみ出していないか」を判定し、収まっていれば何もしません。はみ出している場合だけ、テキストを全角スペース区切りで自分自身と連結して2連結にします。最後に --marquee-dur という CSS カスタムプロパティに秒数をセットして .scrolling クラスを付け、実際のアニメーションは CSS 側に委ねます。

/* src/dashboard/html/styles.ts:293-303 */
.role-row-desc.scrolling .marquee-inner {
  animation: marquee var(--marquee-dur, 10s) linear infinite;
}

.role-row:hover .marquee-inner { animation-play-state: paused; }

@keyframes marquee {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

translateX(-50%) を終端にしているのは、テキストを2連結しているからです。1連結分(全体の50%)だけスライドした時点で、画面に表示されている内容は「連結後半のテキスト」=「元のテキストの先頭」と一致するため、そこで infinite により最初の状態へ瞬時に戻っても切れ目が見えません。速度は inner.scrollWidth / 2 / 25 で「1連結分の幅を25px/秒で走査するのに何秒かかるか」から逆算していて、Math.max(6, ...) により短すぎるテキストでも最低6秒はかけるようにしています。

生SVGでの駒の描画とDEMOTION_MAP

布陣盤(各駒の稼働状況を可視化するSVG)は boardScript.ts が担当します。Chart.js を使っている折れ線・棒グラフ部分(chartsScript.ts)とは違い、盤面はライブラリを使わず document.createElementNS で直接 SVG 要素を組み立てています。

駒1つ分のシルエットは、次の1本の path 要素です。

// src/dashboard/html/boardScript.ts:64-67
const piece = document.createElementNS(SVG_NS, 'path');
piece.setAttribute('d', 'M 0 -30 L 19 -16 L 15 26 L -15 26 L -19 -16 Z');
piece.setAttribute('class', 'bd-piece');
g.appendChild(piece);

d 属性は5点の頂点を直線でつないでいます。上端の尖った頂点 (0, -30) から右上 (19, -16)、右下 (15, 26)、左下 (-15, 26)、左上 (-19, -16) を経て Z で始点に戻る、将棋の駒を模した五角形です。この g 要素(駒1つ分のグループ)は BOARD_NODES で定義した座標に translate() で配置されます。

// src/dashboard/html/boardScript.ts:14-26
const DEMOTION_MAP = { tokin: 'fu', narigin: 'gin', narikei: 'keima', narikyou: 'kyosha', ryuuou: 'hisha', ryuuma: 'kaku' };

const BOARD_NODES = [
  { id: 'user',   x: 450, y: 388, label: IS_JA ? '殿' : 'Lord', color: '#e8e8e8' },
  { id: 'kin',    x: 450, y: 298 },
  { id: 'hisha',  x: 260, y: 196 },
  { id: 'gin',    x: 450, y: 196 },
  { id: 'kaku',   x: 640, y: 196 },
  { id: 'kyosha', x: 240, y: 94  },
  { id: 'keima',  x: 450, y: 94  },
  { id: 'fu',     x: 660, y: 94  },
];

ここで効いてくるのが DEMOTION_MAP です。Jin では分析フェーズを終えた駒が「成り」ます(例: 飛車 → 龍王)。しかし SVG 上のノードは BOARD_NODES で定義した固定の8個しか存在せず、成る前後で別ノードを増やすことはしません。成り駒のID(ryuuou など)が飛んできたときに、盤上ではどのノードを操作すればよいかを引くための変換テーブルが DEMOTION_MAP です。

// src/dashboard/html/boardScript.ts:30-32
function boardBaseId(nodeId) {
  return DEMOTION_MAP[nodeId] ?? nodeId;
}

setNodeState()drawEdge() など、ノードを操作する関数はすべてこの boardBaseId() を経由してから boardState.nodes を引きます。SSE 側から流れてくるイベントの roleId'ryuuou'(龍王)であっても、boardBaseId('ryuuou')'hisha'(飛車)を返すため、実際に色が変わったり矢印が生えたりするのは最初から盤上にある「飛車」ノードです。

表示上の見た目(テキストラベル)だけは、成った際に切り替わります。

// src/dashboard/html/boardScript.ts:91-100
function promoteNode(nodeId) {
  const baseId = boardBaseId(nodeId);
  const node = boardState.nodes[baseId];
  if (!node || node.promoted) return;
  node.promoted = true;
  node.g.classList.add('promoted');
  // 表示名を成り駒名に差し替える(例: 銀 → 成銀)
  const promotedId = Object.keys(DEMOTION_MAP).find(k => DEMOTION_MAP[k] === baseId);
  if (promotedId) node.text.textContent = nodeName(promotedId);
}

DEMOTION_MAP は「成り駒ID → 元の駒ID」の向きで定義されているため、逆方向(元の駒ID → 成り駒ID)を引きたいここでは Object.keys(...).find(...) で線形探索しています。要素数が6個しかない固定テーブルなので、探索コストよりもテーブルを1方向だけ持てば済むシンプルさを優先した実装です。

なお CSS 側(styles.ts)はこのモジュール1つで780行あります。カラー変数からステージのスケール、駒のホバー、マーキーのキーフレームまで、ダッシュボードの見た目に関わる定義を全部ここに集約している分、他のモジュールに比べて分量が大きくなっています。

Jin / Kurari の関連記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?