はじめに
今回の記事は、「Chrome for Developers」のページだと以下に書かれている『ブラウザでの「WebSocketStream」の話』です。
●WebSocketStream: ストリームと WebSocket API の統合 | Capabilities | Chrome for Developers
https://developer.chrome.com/docs/capabilities/web-apis/websocketstream?hl=ja
2つのデバイス間で映像や音声のストリームを送るような話を調べている中で、たどり着いたものの 1つです。
ちなみに上記の調査の中では、他には WebRTC の情報が出てきたりなどしていました。それらなどを見ていた中で、今までに情報を追いかけたことがなかった「ブラウザでの WebSocketStream」がより気になって、それで今回のお試しをやってみる流れにしました。
前回の記事の内容を試した背景
JavaScript で WebSocket + ストリームを扱う話は、今回の「ブラウザでの WebSocketStream」とは異なりますが、直近で以下の記事に書いた内容で少し扱いました。
●ws の「createWebSocketStream」を用いたストリームの処理を Node.js で試す - Qiita
https://qiita.com/youtoy/items/1db0f4280b55e9984d55
その上記の記事の中で「パッケージの ws について、情報を見ていた」ことや、その流れで「createWebSocketStream が気になった」という話を書いていました。
その前段になっていたのが、今回の「ブラウザでの WebSocketStream」の話になります。
ただブラウザの WebSocketStream よりも、上記の「ws の createWebSocketStream」のほうがサクッと試せそうだったので、まずは上記をやってみていたという状況でした。そして今回は「ブラウザでの WebSocketStream」の話です。
関連情報
まずはブラウザでの WebSocketStream について、調べて出てきたいくつかの情報を記載します。
- 冒頭の Chrome for Developers の記事
- WebSocketStream API | Can I use...
- WebSocketStream でクライアントを書く - Web API | MDN
- WebSocketStream - Chrome Platform Status
利用可能なブラウザ
上記の Can I use... の WebSocketStream API に関するページを見ると、現状はおおまかには Chrome系のブラウザでのみ利用可能という感じのようです。
対応ブラウザが限られる点はご注意ください。
コードのサンプル
Chrome for Developers の記事によると、以下の部分で使用方法の例が書かれています。
また、上記の MDN のページでは、「完全なリスト」の部分に実装例が書かれているようです。
また MDN のページ内だと、以下にも情報があるようでした。
●WebSocketStream - Web APIs | MDN
https://developer.mozilla.org/en-US/docs/Web/API/WebSocketStream
これらを見て、とりあえず主に以下の処理関連の部分を把握します。
- サーバーへの接続・切断
- ReadableStream・WritableStream の準備
- サーバーとの間でのデータ送受信
Chrome Platform Status に書かれた情報
上記の Chrome Platform Status のページでは、概要・モチベーションについて説明が書いてあります。
モチベーションのほうを、機械翻訳してみた日本語の分を掲載してみます。
現在の WebSocket API では、受信したメッセージに対してバックプレッシャーをかけることができません。メッセージがページが処理できる速度よりも速く到着すると、レンダー プロセスはそれらのメッセージをメモリにバッファし続けるか、100% の CPU 使用率によって応答不能になるか、あるいはその両方の状況が発生します。
一方、送信するメッセージにバックプレッシャーをかけることは可能ですが、bufferedAmount プロパティをポーリングする必要があり、これは非効率で使い勝手が良くありません。
このような背景で用意されたもののようです。
さらにその下を見ると、以下のサンプルへのリンクが掲載されています。
●websocketstream-explainer/README.md at master · ricea/websocketstream-explainer
https://github.com/ricea/websocketstream-explainer/blob/master/README.md
このあたりは、新旧の API の違いなどが書かれています。
ざっと関連情報を見ていったところで、実装を進めていきます。
実際に試す
ここから、実際に試していきます。
サーバー側
サーバー側は、前回の内容と同じものを使います。
下準備として npm i ws
でパッケージをインストールしておきます。その後、以下を nodeコマンドで実行します。
import WebSocket, { WebSocketServer, createWebSocketStream } from "ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws) => {
const duplex = createWebSocketStream(ws, { encoding: "utf8" });
duplex.on("error", (err) => {
console.error("サーバー側ストリームでエラー:", err);
});
duplex.pipe(duplex);
duplex.on("data", (chunk) => {
console.log("サーバーで受信:", chunk);
});
});
console.log("WebSocket サーバーをポート 8080 で起動");
これで、サーバー側は準備できた状態です。
クライアント側
クライアント側は、ブラウザ上での JavaScript の処理で WebSocketStream API を扱います。
ちなみに、前回の記事で Node.js で作ったクライアント側の処理は以下でした。
import WebSocket, { createWebSocketStream } from "ws";
const ws = new WebSocket("ws://localhost:8080");
const duplex = createWebSocketStream(ws, { encoding: "utf8" });
duplex.on("error", console.error);
duplex.pipe(process.stdout);
process.stdin.pipe(duplex);
処理としては、以下を実装します。
- サーバーへの接続
- 読み書きの処理ができるよう準備
- サーバーへのメッセージ送信をしつつ、送信したメッセージを出力
- サーバーからメッセージを受信したらその内容を出力
クライアント側の実装内容
クライアント側で実装した内容は以下のとおりです。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>WebSocketStream クライアント</title>
</head>
<body>
<h2>WebSocketStream Echo クライアント</h2>
<label for="messageInput">送信メッセージ:</label>
<input id="messageInput" type="text" placeholder="ここに文字列を入力" />
<button id="sendButton">送信</button>
<h3>受信メッセージ</h3>
<pre
id="outputArea"
style="
background: #f4f4f4;
padding: 10px;
height: 200px;
overflow-y: scroll;
"
></pre>
<script type="text/javascript">
(async () => {
const wsStream = new WebSocketStream("ws://localhost:8080");
const { readable: wsReadable, writable: wsWritable } =
await wsStream.opened;
const textDecoder = new TextDecoderStream();
const textEncoder = new TextEncoderStream();
// --- 受信側:WebSocket のバイナリ → TextDecoderStream → reader ---
wsReadable.pipeTo(textDecoder.writable);
const reader = textDecoder.readable.getReader();
// --- 送信側:TextEncoderStream → WebSocket のバイナリ ---
textEncoder.readable.pipeTo(wsWritable);
const writer = textEncoder.writable.getWriter();
async function receiveLoop() {
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
console.log("WebSocketStream の readable がクローズされました");
break;
}
console.log("サーバーから受信:", value);
const outputElem = document.getElementById("outputArea");
outputElem.textContent += value + "\n";
outputElem.scrollTop = outputElem.scrollHeight;
}
} catch (err) {
console.error("受信ストリームでエラー:", err);
}
}
receiveLoop();
document
.getElementById("sendButton")
.addEventListener("click", async () => {
const inputElem = document.getElementById("messageInput");
const text = inputElem.value;
if (text === "") return;
try {
console.log("サーバーへ送信:", text);
await writer.write(text);
inputElem.value = "";
inputElem.focus();
} catch (err) {
console.error("送信でエラー:", err);
}
});
window.addEventListener("beforeunload", async () => {
try {
await writer.close();
await reader.cancel();
} catch (_) {
/* 対処できないため無視 */
}
});
})();
</script>
</body>
</html>
クライアント側の実装の補足
クライアント側で実装した内容は少しだけ補足しておきます。
以下の部分について、これらの型は「wsReadable: ReadableStream」と「wsWritable: WritableStream」といったように、どちらもバイナリ・チャンク(Uint8Array) のストリームになるようです。
const { readable: wsReadable, writable: wsWritable } =
await wsStream.opened;
これについて、ブラウザ上では文字列を扱うようにしたいので、変換する処理を入れています。具体的には、以下の部分です。
// --- 受信側:WebSocket のバイナリ → TextDecoderStream → reader ---
wsReadable.pipeTo(textDecoder.writable);
const reader = textDecoder.readable.getReader();
// --- 送信側:TextEncoderStream → WebSocket のバイナリ ---
textEncoder.readable.pipeTo(wsWritable);
const writer = textEncoder.writable.getWriter();
上記の受信側をまずは見てみます。
// --- 受信側:WebSocket のバイナリ → TextDecoderStream → reader ---
wsReadable.pipeTo(textDecoder.writable);
const reader = textDecoder.readable.getReader();
「textDecoder.writable」は、Uint8Array を書き込むための書き込み先になります。そして「wsReadable.pipeTo(textDecoder.writable)」は、WebSocket で受信したバイナリ(Uint8Array)を TextDecoderStream の書き込み側 (.writable) へ流し込んでいます。
その下の「textDecoder.readable」は、先ほど pipeTo で Uint8Array を送り込んだ結果を、内部で UTF-8 デコード済みの文字列にしていて、それを読み出せるものになります。.getReader() を呼ぶことで、読み取り用のリーダーオブジェクトを取得します。
次に送信側のほうを見てみます。
// --- 送信側:TextEncoderStream → WebSocket のバイナリ ---
textEncoder.readable.pipeTo(wsWritable);
const writer = textEncoder.writable.getWriter();
上記の送信側の「textEncoder.writable」は writer.write("【何かの文字列】") のように文字列を渡すと、内部で UTF-8 バイト列 (Uint8Array) にエンコードします。そのバイナリは textEncoder.readable から取り出せます。
textEncoder.readable.pipeTo(wsWritable) の部分は、先ほどのエンコードされた UTF-8 バイト列 (Uint8Array) を、直接 wsWritable(WebSocket の送信用バイナリストリーム)に流し込みます。
これによって、writer.write("【何かの文字列】") という処理を行った時に、文字列が UTF-8バイト列に変換されて、その結果が pipeTo で WebSocket の送信ストリーム (wsWritable) に流されます。
これらの部分が、ブラウザ上で文字列を扱う処理と、ネットワーク上でバイト列が送受信される部分の間をつなぐ形になります。
表にすると、以下となります(こちらの表は、生成AI でまとめて出力してみ)。
要素 | 型 | 役割 |
---|---|---|
wsReadable |
ReadableStream<Uint8Array> |
WebSocket から届く生のバイナリを読み込む読み取り専用ストリーム |
wsWritable |
WritableStream<Uint8Array> |
バイナリを書き込むと WebSocket で送信する書き込み専用ストリーム |
TextDecoderStream() |
オブジェクト |
Uint8Array を UTF-8 でデコードし、文字列を吐き出すストリーム |
textDecoder.writable |
WritableStream<Uint8Array> |
デコードのための入力先(バイナリを受け取る) |
textDecoder.readable |
ReadableStream<string> |
デコード済み文字列(string )を読み出せる |
textDecoder.readable.getReader() |
ReadableStreamDefaultReader<string> |
文字列チャンクを逐次的に .read() できるリーダー |
TextEncoderStream() |
オブジェクト | 文字列を UTF-8 でエンコードし、Uint8Array を吐き出すストリーム |
textEncoder.writable |
WritableStream<string> |
エンコードのための入力先(文字列を受け取る) |
textEncoder.readable |
ReadableStream<Uint8Array> |
エンコード済みのバイナリ(Uint8Array )を読み出せる |
textEncoder.readable.pipeTo(wsWritable) |
メソッド | エンコード済みバイナリを直接 WebSocket の送信ストリームに流す |
textEncoder.writable.getWriter() |
WritableStreamDefaultWriter<string> |
文字列を書き込むためのライターオブジェクト |
writer.write("text") |
メソッド | 文字列を TextEncoderStream に渡し → 自動でバイナリにする |
wsReadable.pipeTo(textDecoder.writable) |
メソッド | WebSocket のバイナリを TextDecoderStream に流し込む |
動作確認
それでは作ったものの動作確認です。
サーバー・クライアントを起動しただけの状態は、以下のとおりです。
ここで、クライアント側から「test」という文字列を送ってみます。
以下の赤矢印で示している部分は、デバッグ用にログ出力した「クライアント側がサーバーに送信した文字列」と「サーバー側から受信した文字列」です。サーバー側は、受信した内容をそのまま返す実装なので、これらは同じ文字列になります。
また緑矢印で示している部分は、「サーバー側が受信したメッセージのログ出力」と「クライアント側が受信したメッセージを Webページ上に表示させたもの」です。
うまく、文字列のやりとりはできていそうです。
さらに、文字列をいくつか送ってみます。そうすると、以下のようになりました。
問題なく、文字列の送受信ができていることを確認できました。