ブラウザの WebSocketStream API を試してみる（Node.js で用意した WebSocketサーバーとのやりとり）

## はじめに

今回の記事は、「Chrome for Developers」のページだと以下に書かれている『ブラウザでの「WebSocketStream」の話』です。


●WebSocketStream: ストリームと WebSocket API の統合  |  Capabilities  |  Chrome for Developers
https://developer.chrome.com/docs/capabilities/web-apis/websocketstream?hl=ja

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/eed9a27b-6e36-43c7-b427-8592ca2b5051.png)


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 の記事](https://developer.chrome.com/docs/capabilities/web-apis/websocketstream?hl=ja)
- [WebSocketStream API | Can I use...](https://caniuse.com/mdn-api_websocketstream)
- [WebSocketStream でクライアントを書く - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/WebSockets_API/Using_WebSocketStream)
- [WebSocketStream - Chrome Platform Status](https://chromestatus.com/feature/5189728691290112)


### 利用可能なブラウザ

上記の [Can I use... の WebSocketStream API に関するページ](https://caniuse.com/mdn-api_websocketstream)を見ると、現状はおおまかには Chrome系のブラウザでのみ利用可能という感じのようです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/a7b885fc-c465-4fec-84e0-0b1d7c753a3f.png)

対応ブラウザが限られる点はご注意ください。


### コードのサンプル

[Chrome for Developers の記事](https://developer.chrome.com/docs/capabilities/web-apis/websocketstream?hl=ja)によると、以下の部分で使用方法の例が書かれています。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/475a2874-dab5-4b15-90db-7d6155ae74b2.png)


また、[上記の MDN のページ](https://developer.mozilla.org/ja/docs/Web/API/WebSockets_API/Using_WebSocketStream)では、「[完全なリスト](https://developer.mozilla.org/ja/docs/Web/API/WebSockets_API/Using_WebSocketStream#%E5%AE%8C%E5%85%A8%E3%81%AA%E3%83%AA%E3%82%B9%E3%83%88)」の部分に実装例が書かれているようです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/ece138fb-01ef-4ce8-a09b-81d84606d6c7.png)

また MDN のページ内だと、以下にも情報があるようでした。

●WebSocketStream - Web APIs | MDN
　https://developer.mozilla.org/en-US/docs/Web/API/WebSocketStream


これらを見て、とりあえず主に以下の処理関連の部分を把握します。

- サーバーへの接続・切断
- ReadableStream・WritableStream の準備
- サーバーとの間でのデータ送受信



### Chrome Platform Status に書かれた情報

上記の [Chrome Platform Status](https://chromestatus.com/feature/5189728691290112) のページでは、概要・モチベーションについて説明が書いてあります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/3a606872-7fee-40bb-a785-7b476e0138dc.png)

モチベーションのほうを、機械翻訳してみた日本語の分を掲載してみます。

> 現在の 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 の違いなどが書かれています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/5f448b1d-1903-4bf9-8428-e4fb426bc470.png)


ざっと関連情報を見ていったところで、実装を進めていきます。



## 実際に試す

ここから、実際に試していきます。

### サーバー側

サーバー側は、[前回の内容と同じもの](https://qiita.com/youtoy/items/1db0f4280b55e9984d55)を使います。

下準備として `npm i ws` でパッケージをインストールしておきます。その後、以下を nodeコマンドで実行します。

```javascript
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 で作ったクライアント側の処理は以下でした。

```javascript
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);
```

処理としては、以下を実装します。

- サーバーへの接続
- 読み書きの処理ができるよう準備
- サーバーへのメッセージ送信をしつつ、送信したメッセージを出力
- サーバーからメッセージを受信したらその内容を出力


#### クライアント側の実装内容

クライアント側で実装した内容は以下のとおりです。


```html
<!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<Uint8Array>」と「wsWritable: WritableStream<Uint8Array>」といったように、どちらもバイナリ・チャンク（Uint8Array） のストリームになるようです。

```javascript
        const { readable: wsReadable, writable: wsWritable } =
          await wsStream.opened;
```

これについて、ブラウザ上では文字列を扱うようにしたいので、変換する処理を入れています。具体的には、以下の部分です。

```javascript
        // --- 受信側：WebSocket のバイナリ → TextDecoderStream → reader ---
        wsReadable.pipeTo(textDecoder.writable);
        const reader = textDecoder.readable.getReader();

        // --- 送信側：TextEncoderStream → WebSocket のバイナリ ---
        textEncoder.readable.pipeTo(wsWritable);
        const writer = textEncoder.writable.getWriter();
```

上記の受信側をまずは見てみます。

```javascript
        // --- 受信側：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() を呼ぶことで、読み取り用のリーダーオブジェクトを取得します。

次に送信側のほうを見てみます。

```javascript
        // --- 送信側：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 に流し込む   |



#### 動作確認

それでは作ったものの動作確認です。

サーバー・クライアントを起動しただけの状態は、以下のとおりです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/2d450327-a654-4792-bf75-37462e437b0d.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/a7e72234-6988-4b09-a41e-60dfe14eeba6.png)


ここで、クライアント側から「test」という文字列を送ってみます。

以下の赤矢印で示している部分は、デバッグ用にログ出力した「クライアント側がサーバーに送信した文字列」と「サーバー側から受信した文字列」です。サーバー側は、受信した内容をそのまま返す実装なので、これらは同じ文字列になります。

また緑矢印で示している部分は、「サーバー側が受信したメッセージのログ出力」と「クライアント側が受信したメッセージを Webページ上に表示させたもの」です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/9670a1bf-8e08-4e52-9c35-ec5e55e7c5d3.png)

うまく、文字列のやりとりはできていそうです。

さらに、文字列をいくつか送ってみます。そうすると、以下のようになりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/9d24698c-6e0b-45ac-839d-5718dbc8fec0.png)

問題なく、文字列の送受信ができていることを確認できました。