htmx 逆引きレシピ
WebSocketで双方向にするには?

公開日:
最終更新日:

WebSocketを使うと、サーバとブラウザが“つながりっぱなし”になり、更新を待たずに即反映できます。

このレシピでは チャット(双方向) と 共同編集(共有メモ) を1つの接続で扱い、さらに サーバからの即時通知 も流して、リアルタイム風の画面を作ります。

使用するhtmx属性

  • hx-ext="ws":WebSocket拡張を有効化し、ws-connect/ws-send を使えるようにします。
  • ws-connect:WebSocketの接続先を指定し、ページ表示中はサーバと接続を維持します(HTTPがhttpsなら接続先はwssが必要です)。
  • ws-send:フォーム送信をWebSocket経由にし、入力値をサーバへ送ります(サーバ側で受け取って全員へ配信できます)。
  • hx-trigger:送信タイミングを制御します(共同編集は keyup changed delay:300ms などで“入力しながら送る”形にできます)。
  • hx-swap-oob:サーバからのHTML断片で、指定した要素を“画面の別の場所”でも同時更新します(チャットの先頭追加や、共有メモ枠の差し替えに使えます)。

利用シーン

  • 「チャット/共同編集」:誰かの操作を、他の閲覧者にも即反映して“リアルタイム感”を出したいときに使えます。
  • 「サーバからの即時反映」:オンライン人数やジョブ完了など、サーバ起点の更新をページにそのまま押し込みたいときに便利です。
  • 「ポーリングより即時性が欲しい」:一定間隔で取りに行くのではなく、更新が起きた瞬間に届けたい場合に向きます。

WebSocketで双方向にするには?

チャット(双方向)共同編集(共有メモ)を同じWebSocket接続で扱い、さらにサーバからの即時反映(オンライン人数・時刻・自動通知)も流します。
※私のレンタルサーバーでは動きません。よって、ローカルで動かす用のコードになってます。

HTML

<div class="DEMO">

	<h4>WebSocketで双方向にするには?</h4>

	<div
		id="DEMO_WS_ROOT"
		class="CARD"
		hx-ext="ws"
		ws-connect="ws://localhost:8080"
	>
		<div class="ROW" style="justify-content:space-between;">
			<div class="ROW">
				<span id="DEMO_WS_STATE" class="BADGE is-warn">WS:未接続</span>
				<span id="DEMO_WS_ONLINE" class="BADGE">オンライン:-</span>
				<span id="DEMO_WS_SERVER_TIME" class="BADGE">サーバ時刻:-</span>
			</div>

			<div class="ROW">
				<button id="DEMO_WS_CLEAR_LOCAL" class="BTN" type="button">ローカル表示クリア</button>
			</div>
		</div>

		<hr style="border:none; border-top:1px dashed rgba(0,0,0,.12); margin:1rem 0;">

		<div class="GRID">

			<!-- =========================
				左:チャット(双方向)
			========================= -->
			<section>
				<h3 style="margin:0;">① チャット(双方向)</h3>
				<p class="HTMX-NOTE" style="margin:.35rem 0 0;">
					フォームを <code>ws-send</code> で送信すると、サーバが全員にブロードキャストして即反映します。
				</p>

				<form
					id="DEMO_WS_CHAT_FORM"
					class="FORM"
					ws-send
				>
					<!-- kind はサーバ側で分岐するための種別 -->
					<input type="hidden" name="kind" value="chat">

					<label class="HTMX-NOTE">
						送信者
						<select name="user">
							<option value="tanaka">tanaka</option>
							<option value="sato">sato</option>
							<option value="suzuki">suzuki</option>
						</select>
					</label>

					<label class="HTMX-NOTE">
						メッセージ
						<input type="text" name="message" value="" placeholder="例:承認お願いします!">
					</label>

					<button class="BTN is-ok" type="submit">送信</button>
				</form>

				<ul id="DEMO_WS_CHAT_LIST" class="CHAT_LIST">
					<li class="CHAT_ITEM is-placeholder">
						<div class="CHAT_HEAD">
							<span class="CHAT_USER">info</span>
							<span class="CHAT_TIME">--:--:--</span>
						</div>
						<div class="CHAT_MSG">(ここにチャットが流れます)</div>
					</li>
				</ul>
			</section>

			<!-- =========================
				右:共同編集(共有メモ)
			========================= -->
			<section>
				<h3 style="margin:0;">② 共同編集(共有メモ)</h3>
				<p class="HTMX-NOTE" style="margin:.35rem 0 0;">
					入力内容を一定間隔で <code>ws-send</code> し、サーバが保持する「最新」を全員へ即反映します。
				</p>

				<form
					id="DEMO_WS_NOTE_FORM"
					class="FORM"
					ws-send
					hx-trigger="keyup changed delay:300ms from:#DEMO_WS_NOTE_TEXT"
				>
					<!-- kind はサーバ側で分岐するための種別 -->
					<input type="hidden" name="kind" value="note">

					<label class="HTMX-NOTE">
						あなたの編集(ローカル入力)
						<textarea id="DEMO_WS_NOTE_TEXT" name="note" placeholder="例:端末貸与の承認フローを整理する"></textarea>
					</label>

					<p class="HTMX-NOTE">
						下の「共有メモ(サーバ保持)」は、<code>hx-swap-oob</code> でサーバから即時更新されます。
					</p>
				</form>

				<!-- サーバから outerHTML で差し替えられる枠 -->
				<div id="DEMO_WS_NOTE_VIEW" class="CARD" style="margin-top:.6rem;">
					<strong>共有メモ(サーバ保持)</strong>
					<div class="ROW" style="margin-top:.35rem;">
						<span id="DEMO_WS_NOTE_VER" class="BADGE">ver:-</span>
						<span id="DEMO_WS_NOTE_BY" class="BADGE">更新者:-</span>
					</div>
					<pre id="DEMO_WS_NOTE_PRE" class="HTMX-NOTE">(まだ共有メモはありません)</pre>
				</div>
			</section>
		</div>

		<p class="HTMX-NOTE" style="margin-top:1rem;">
			※ サーバは定期的に「オンライン人数」「サーバ時刻」「システム通知」もプッシュします(<strong>サーバからの即時反映</strong>の例)。
		</p>

	</div>

</div>

<script src="/htmx/ws.min.js" defer></script>
<script>
(() => {
	// WS状態バッジ
	const state = document.getElementById("DEMO_WS_STATE");

	// クリア(ローカル表示のみ)
	document.getElementById("DEMO_WS_CLEAR_LOCAL").addEventListener("click", () => {
		document.getElementById("DEMO_WS_CHAT_LIST").innerHTML =
			`<li class="CHAT_ITEM is-placeholder">
				<div class="CHAT_HEAD">
					<span class="CHAT_USER">info</span>
					<span class="CHAT_TIME">--:--:--</span>
				</div>
				<div class="CHAT_MSG">(ここにチャットが流れます)</div>
			</li>`;
	});

	// 送信後:チャット入力だけリセットしたい
	document.body.addEventListener("htmx:wsAfterSend", (e) => {
		if (!e.detail || !e.detail.elt) return;
		if (e.detail.elt.id !== "DEMO_WS_CHAT_FORM") return;

		const msg = e.detail.elt.querySelector('input[name="message"]');
		if (msg) msg.value = "";
	});

	// WS接続状態表示
	document.body.addEventListener("htmx:wsOpen", () => {
		state.textContent = "WS:接続中";
		state.classList.remove("is-warn");
		state.classList.add("is-ok");
	});

	document.body.addEventListener("htmx:wsClose", () => {
		state.textContent = "WS:切断";
		state.classList.remove("is-ok");
		state.classList.add("is-warn");
	});

	document.body.addEventListener("htmx:wsError", () => {
		state.textContent = "WS:エラー";
		state.classList.remove("is-ok");
		state.classList.add("is-warn");
	});
})();
</script>

js(_ws_server.js)

/**
 * WebSocketデモサーバ(htmx ws拡張向け)
 * - 受信:htmx ws-send から来る JSON を処理({kind:"chat", ... , HEADERS:{...}})
 * - 送信:HTMLフラグメント(hx-swap-oob で即反映)
 *
 * 起動:
 *   npm i ws
 *   node _ws_server.js
 */

const http = require("http");
const WebSocket = require("ws");

// サーバを起動するポート
const PORT = 8080;

// 共有メモ(サーバが持つ最新)
let sharedNote = "";
let sharedVer = 0;
let sharedBy = "system";

// HTTPサーバ(ws用)
const server = http.createServer((req, res) => {
	res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
	res.end("WS server is running.\n");
});

// WebSocketサーバ
const wss = new WebSocket.Server({ server });

// HTMLエスケープ
const esc = (s) =>
	String(s)
		.replaceAll("&", "&")
		.replaceAll("<", "<")
		.replaceAll(">", ">")
		.replaceAll('"', """)
		.replaceAll("'", "'");

// 全員に送る
const broadcast = (html) => {
	wss.clients.forEach((ws) => {
		if (ws.readyState === WebSocket.OPEN) ws.send(html);
	});
};

// オンライン数・時刻を更新(サーバからの即時反映)
const pushStatus = () => {
	const now = new Date();
	const hh = String(now.getHours()).padStart(2, "0");
	const mm = String(now.getMinutes()).padStart(2, "0");
	const ss = String(now.getSeconds()).padStart(2, "0");

	const online =
		`<span id="DEMO_WS_ONLINE" class="BADGE is-ok" hx-swap-oob="outerHTML">` +
		`オンライン:${wss.clients.size}` +
		`</span>`;

	const time =
		`<span id="DEMO_WS_SERVER_TIME" class="BADGE" hx-swap-oob="outerHTML">` +
		`サーバ時刻:${hh}:${mm}:${ss}` +
		`</span>`;

	broadcast(online + time);
};

// 共有メモを配信(共同編集の即反映)
const pushNote = () => {
	const noteView =
		`<div id="DEMO_WS_NOTE_VIEW" class="CARD" style="margin-top:.6rem;" hx-swap-oob="outerHTML">` +
		`<strong>共有メモ(サーバ保持)</strong>` +
		`<div class="ROW" style="margin-top:.35rem;">` +
		`<span id="DEMO_WS_NOTE_VER" class="BADGE">ver:${sharedVer}</span>` +
		`<span id="DEMO_WS_NOTE_BY" class="BADGE">更新者:${esc(sharedBy)}</span>` +
		`</div>` +
		`<pre id="DEMO_WS_NOTE_PRE" class="HTMX-NOTE">` +
		`${sharedNote ? esc(sharedNote) : "(まだ共有メモはありません)"}` +
		`</pre>` +
		`</div>`;

	broadcast(noteView);
};

// 受信メッセージをJSONとして読み、必要ならフォールバックする
const parseIncoming = (text) => {
	// 基本:ws-send は JSON で来る(HEADERS を含む)
	try {
		const obj = JSON.parse(text);
		// 期待する形でなければ空にする
		if (obj && typeof obj === "object") return obj;
	} catch (e) {
		// JSONでない場合はフォールバックへ
	}

	// 念のため:クエリ形式で来た場合(古い実装など)
	try {
		return Object.fromEntries(new URLSearchParams(text));
	} catch (e) {
		return {};
	}
};

// 接続したとき
wss.on("connection", (ws) => {
	// 接続直後にステータスと共有メモを送る
	pushStatus();
	pushNote();

	// 受信
	ws.on("message", (buf) => {
		// 受信を文字列化
		const text = buf.toString("utf8");

		// JSON(基本)としてパース
		const data = parseIncoming(text);

		// 種別(kind)で分岐
		const kind = String(data.kind ?? "").toLowerCase();

		// チャット
		if (kind === "chat") {
			// 送信者
			const user = String(data.user ?? "anon").trim();

			// メッセージ
			const message = String(data.message ?? "").trim();

			// 空なら何もしない
			if (!message) return;

			// 時刻
			const now = new Date();
			const hh = String(now.getHours()).padStart(2, "0");
			const mm = String(now.getMinutes()).padStart(2, "0");
			const ss = String(now.getSeconds()).padStart(2, "0");

			// 先頭に追加(afterbegin)
			const isSystem = String(user).toLowerCase() === "system";
			const li =
				`<li class="CHAT_ITEM${isSystem ? " is-system" : ""}" hx-swap-oob="afterbegin:#DEMO_WS_CHAT_LIST">` +
					`<div class="CHAT_HEAD">` +
						`<span class="CHAT_USER">${esc(user)}</span>` +
						`<span class="CHAT_TIME">${hh}:${mm}:${ss}</span>` +
					`</div>` +
					`<div class="CHAT_MSG">${esc(message)}</div>` +
				`</li>`;

			// 全員へ配信
			broadcast(li);

			// ついでにステータス更新
			pushStatus();

			return;
		}

		// 共同編集(共有メモ)
		if (kind === "note") {
			// メモ本文
			const note = String(data.note ?? "");

			// 更新者(フォームに user を追加したいならここで受け取れる)
			const by = String(data.user ?? "someone").trim() || "someone";

			// サーバ側の最新を更新
			sharedNote = note;
			sharedVer += 1;
			sharedBy = by;

			// 全員へ最新を即反映
			pushNote();

			// ついでにステータス更新
			pushStatus();

			return;
		}

		// どれにも当てはまらない場合は何もしない(デモなので静かに捨てる)
	});

	// 切断したとき
	ws.on("close", () => {
		pushStatus();
	});
});

// サーバから定期的にステータスをプッシュ(即時反映の例)
setInterval(() => {
	pushStatus();
}, 1500);

// たまに「サーバ発のシステム通知」を流す(即時反映の例)
setInterval(() => {
	const now = new Date();
	const hh = String(now.getHours()).padStart(2, "0");
	const mm = String(now.getMinutes()).padStart(2, "0");
	const ss = String(now.getSeconds()).padStart(2, "0");

	const li =
		`<li class="CHAT_ITEM is-system" hx-swap-oob="afterbegin:#DEMO_WS_CHAT_LIST">` +
			`<div class="CHAT_HEAD">` +
				`<span class="CHAT_USER">system</span>` +
				`<span class="CHAT_TIME">${hh}:${mm}:${ss}</span>` +
			`</div>` +
			`<div class="CHAT_MSG">サーバからの即時通知です</div>` +
		`</li>`;

	broadcast(li);
}, 12000);

// 起動
server.listen(PORT, "127.0.0.1", () => {
	console.log(`WS server listening on ws://127.0.0.1:${PORT}`);
});

CSS

.CARD{border:1px solid rgba(0,0,0,.12); border-radius:.8rem; padding:1rem; background:#fff}
.HTMX-NOTE{opacity:.85; font-size:.92rem; line-height:1.55}
.BADGE{display:inline-flex; align-items:center; padding:.15rem .55rem; border-radius:999px; font-size:.85rem; border:1px solid rgba(0,0,0,.15); background:#fff}
.BADGE.is-ok{background:#e8fff1}
.BADGE.is-warn{background:#fff4e5}
.ROW{display:flex; gap:.6rem; align-items:center; flex-wrap:wrap}
.GRID{display:grid; gap:1rem}
@media (min-width: 900px){ .GRID{grid-template-columns: 1fr 1fr} }
.CHAT_LIST{
	margin:.6rem 0 0;
	padding:0;
	list-style:none;
	max-height:14rem;
	overflow:auto;
	border-top:1px dashed rgba(0,0,0,.12);
	padding-top:.6rem;
	display:grid;
	gap:.5rem;
}
.CHAT_ITEM{
	border:1px solid rgba(0,0,0,.12);
	border-radius:.75rem;
	padding:.55rem .7rem;
	display:grid;
	gap:.25rem;
}
.CHAT_HEAD{
	display:flex;
	align-items:baseline;
	justify-content:space-between;
	gap:.75rem;
}
.CHAT_USER{font-weight:700}
.CHAT_TIME{
	opacity:.7;
	font-size:.85rem;
	white-space:nowrap;
}
.CHAT_MSG{
	white-space:pre-wrap;
	line-height:1.55;
}
.CHAT_ITEM.is-system{
	opacity:.9;
}
.CHAT_ITEM.is-placeholder{
	opacity:.75;
	border-style:dashed;
}
.CHAT_MSG{white-space:pre-wrap;}
.TIME{opacity:.7; font-size:.85em; margin-left:.35rem}
.FORM{display:grid; gap:.6rem}
textarea{min-height:7rem; resize:vertical}
pre{white-space:pre-wrap; word-break:break-word; margin:.5rem 0 0}

コマンド

cd /mochimochimikan.com/htmx/demo
npm init -y
npm i ws
node _ws_server.js

デモ

WebSocketで双方向にするには?

WS:未接続 オンライン:- サーバ時刻:-

① チャット(双方向)

フォームを ws-send で送信すると、サーバが全員にブロードキャストして即反映します。

  • info --:--:--
    (ここにチャットが流れます)

② 共同編集(共有メモ)

入力内容を一定間隔で ws-send し、サーバが保持する「最新」を全員へ即反映します。

下の「共有メモ(サーバ保持)」は、hx-swap-oob でサーバから即時更新されます。

共有メモ(サーバ保持)
ver:- 更新者:-
(まだ共有メモはありません)

※ サーバは定期的に「オンライン人数」「サーバ時刻」「システム通知」もプッシュします(サーバからの即時反映の例)。

解説

① チャット(双方向)

  • チャットフォームに ws-send を付け、送信をWebSocket経由にしています。
  • サーバは受け取った内容をHTML(<li ... hx-swap-oob="afterbegin:#CHAT">)として全員へ配信し、チャット一覧の先頭へ追加します。
  • 結果の反映はHTTPレスポンスではなく、サーバからのプッシュで即時に行われます。

② 共同編集(共有メモ)

  • テキスト入力のたびに送信したいので、フォームに hx-trigger="keyup changed delay:300ms" を付けて“入力しながら送る”形にしています。
  • サーバ側では「共有メモの最新状態」を保持し、更新が来たら全員へ配信して、共有メモ枠を hx-swap-oob="outerHTML" で差し替えます。
  • これにより、誰かが入力すると他の画面にも同じ内容が即反映されます。

③ サーバからの即時反映(プッシュ通知)

  • サーバは一定間隔で「オンライン人数」「サーバ時刻」「システム通知」などを配信し、画面の各バッジを hx-swap-oob で更新します。
  • ユーザー操作がなくても更新されるため、監視ダッシュボードや通知UIの雰囲気を作りやすいです。

※このデモはWebSocketサーバの常駐が必要です。レンタルサーバではWebSocketのプロキシ設定や常駐プロセスが使えない場合があるため、 本ページはローカル環境(HTTP)での実行を前提にしています。ローカルでは ws://localhost:8080 に接続して動作します。

次に読むオススメレシピ

このページの著者

もちもちみかん(システムエンジニア)

社内SEとしてグループ企業向けの業務アプリを要件定義〜運用まで一気通貫で担当しています。

経験:Webアプリ/業務システム

得意:PHP・JavaScript・MySQL・CSS

個人実績:フォーム生成基盤クイズ学習プラットフォーム

詳しいプロフィールはこちら!  もちもちみかんのプロフィール

もちもちみかん0系くん
TOPへ

もちもちみかん.comとは


このサイトでは、コーディングがめんどうくさい人向けのお助けツールとして、フォームやCSSをノーコードで生成できる、
 もちもちみかん.forms
 もちもちみかん.css1
 もちもちみかん.css2
と言ったジェネレーターを用意してます。

また、このサイトを通じて、「もちもちみかん」のかわいさを普及したいとかんがえてます!