SharedWorkerを使ってWebSocketやSSEの接続を軽減するサンプル

# 参考

- [Scaling WebSocket Connections using Shared Workers | Ayush Gupta](https://ayushgp.github.io/scaling-websockets-using-sharedworkers/)

※この投稿は、↑のページを理解したくて、私が勉強してみたことをまとめた内容になります:bow:

# やりたいこと

WebSocketを使うとサーバーとクライアントで双方向にデータを送信できます。
クライアントはサーバーとの接続を開きっぱなしにします。

なので例えばAさんが、WebSocketと通信するページを10タブ開いた場合は、Aさんだけでサーバーと10接続することになります。

![computer_server1 (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/c95ab811-3f7e-3f05-3fa3-43f99241773a.png)

そこでSharedWorkerからWebSocketと接続することで、Aさんが10タブ開いたとしてもサーバーとの接続は1つだけにすることができます。

![computer_server1 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/547d1627-89e7-faca-ec41-8f5edb98fa5f.png)

---

今回私が最終的に作成したコードはこちらです

https://github.com/okumurakengo/shared-worker-sample

# SharedWorkerとは

- [SharedWorker - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/SharedWorker)

SharedWorkerは[Web Worker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API)の一種です。
Web Workerとはメイン実行スレッドとは別に、バックグラウンドスレッドでスクリプト操作を実行できます。負荷の高い処理をメインスレッドで実行したくない場合に、Web Workerで処理を行うといった用途で使います。

SharedWorkerは複数ウィンドウ、複数タブからアクセスできる共有のWorkerです。
使い方もWeb Workerとは微妙に違います。

# 1. WebSocketサンプル

### 1-1. 簡単にWebSocketと通信

Nodejsの[ws](https://github.com/websockets/ws)で簡単にWebSocketを使ってみます。
**※まだSharedWorkerは出てきません。**

```bash
yarn add ws
```

```server.js
const http = require("http");
const fs = require("fs");
const WebSocket = require("ws");

// httpサーバー作成
const server = http.createServer();
server.on("request", async (req, res) => {
    try {
        res.write(await fs.promises.readFile(`${__dirname}${req.url === "/" ? "/index.html" : req.url}`));
    } catch(e) {
        console.log(e.message)
        res.writeHead(404)
    }
    res.end();
});
server.listen(8000);
console.log("http server listening ...");

// websockerサーバー作成
const wss = new WebSocket.Server({ port: 8001 });
wss.on("connection", ws => {
    console.log("Socket connected successfully");

    ws.on("message", message => {
        console.log(`Received ${message}`);

        for (client of wss.clients) {
            client.send(`${message} from server!`);
            client.send(`現在のクライアントとの接続数 : ${[...wss.clients].length}`);
        }
    });

    ws.on("close", () => {
        console.log("I lost a client");
    });
});

```

```index.html
<!DOCTYPE html>
<meta charset="UTF-8">
<title>Document</title>
<script src="client.js" defer></script>

<input type="text" id="text" value="hello">
<input type="button" id="button" value="送信">
```

```client.js
const ws = new WebSocket("ws://localhost:8001");

ws.addEventListener("open", e => {
    console.log("Socket Opne");
});

// サーバーからデータを受け取る
ws.addEventListener("message", e => {
    console.log(e.data);
});

// サーバーにデータを送る
button.addEventListener("click", e => {
    ws.send(text.value);
});
```

```console
$ node server.js # サーバー起動
http server listening ...
```

この状態で http://localhost:8000 を開いて、送信ボタンを押すと、WebSocketと通信できました。
3つのウィンドウから開いた状態だと、WebSocketと接続している数が3つになることも確認できました。

<img width="738" alt="Screen Shot 2019-11-23 at 22.37.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/bcf13368-a4f9-c157-3a1a-485e520db98c.png">

### 1-2. SharedWorkerからWebSocketと通信

次に、
`メインスレッドのjs -> SharedWorker -> サーバー`とデータ送信する動作と、
`サーバー -> SharedWorker -> メインスレッドのjs`とデータ送信する動作に変更します。

`client.js`を変更し、新しく`worker.js`を作成します。

```client.js
const worker = new SharedWorker("worker.js")
worker.port.start();

button.addEventListener("click", e => {
    console.log(`メインスクリプトからSharedWorkerにデータ送信 「${text.value}」`)
    worker.port.postMessage(text.value)
});

worker.port.addEventListener("message", e => {
    console.log(`メインスクリプトでSharedWorkerからのデータ受信 「${e.data}」`)
});
```

```worker.js
const ws = new WebSocket("ws://localhost:8001");

ws.addEventListener("open", e => {
    console.log("Socket Opne");
});

// サーバーからデータを受け取る
ws.addEventListener("message", e => {
    console.log(`SharedWorkerでサーバーからデータ受信し、メインスクリプトへデータ送信 : 「${e.data}」`)
    // 開いている全ての複数ウィンドウ、複数タブへメッセージ送信
    for (const connection of connections) {
        connection.postMessage(e.data);
    }
});

let connections = [];
self.addEventListener("connect", e => {
    const port = e.ports[0];

    // タブやウィンドウを開くたびに接続を配列に保存します。
    // ※しかし、これだとウィンドウを閉じても配列に残り続けてしまったり、
    //  リロードすると前の接続が残ったまま新しい接続が追加されてしまうといった問題があります。
    //  Broadcast Channel API を使うと簡単に解決できるのでそちらを使った方が良いと思います。
    //  Broadcast Channel API を使ったサンプルは次に説明します。
    connections.push(port);

    port.addEventListener("message", function (e) {
        console.log(`SharedWorkerでメインスクリプトからデータ受信し、サーバーへデータ送信 : 「${e.data}」`)
        ws.send(e.data);
    });

    port.start(); // addEventListenerを使用する場合に必要、onmessageを使う場合は暗黙的に呼び出される
})
```

SharedWorkerからWebSockerに接続することで、複数のウィンドウを開いている場合でも、WebSocketとの接続は1つだけにすることができました。

<img width="738" alt="Screen Shot 2019-11-23 at 23.32.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/44a52c1f-c93f-c9ef-25c1-dfbe6148dc17.png">

---

※SharedWorker内で`console.log`を使用した場合は、普通に開発者ツールのコンソールには出てきません。

`chrome://inspect/#workers` を開き、そこから開けるコンソールに表示されます。

<img width="1136" alt="Screen Shot 2019-11-23 at 23.35.25.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/7d4aea90-cb73-b7b2-48b2-33450cfdf34f.png">

### 1-3. Broadcast Channel API を使ってSharedWorkerからメインスクリプトへデータを送信するようにする

先ほどの例ではタブやウィンドウを開くたびに、`connections` という配列に`e.ports[0]`が追加されていくようにしたのですが、
ブラウザを閉じても配列に残ったままになってしまう、
リロードした場合も、前の接続が残ったまま新しい接続が追加されてしまうといった問題があります。

```woker.js

// ...

let connections = [];
self.addEventListener("connect", e => {
    const port = e.ports[0];

    connections.push(port);

// ...
```

---

SharedWorkerからBroadcast Channel API を使ってメインスクリプトへデータを送信するのが良いようです。

[Broadcast Channel API - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API)

`BroadcastChannel`を使うことで、開いている全てのタブ、ウィンドウ、iframeにデータ送信できます。
※実際にデータを送信したウィンドウ(またはタブなど)では受信しません。自分以外の全てでデータを受信できます。

`client.js`と`worker.js`を`BroadcastChannel`を使うように変更します。

```diff:client.js
+ const bc = new BroadcastChannel("WebSocketChannel");
  const worker = new SharedWorker("worker.js")
  worker.port.start();

  button.addEventListener("click", e => {
      console.log(`メインスクリプトからSharedWorkerにデータ送信 「${text.value}」`)
      worker.port.postMessage(text.value)
  });


+ bc.addEventListener("message", e => {
- worker.port.addEventListener("message", e => {
      console.log(`メインスクリプトでSharedWorkerからのデータ受信 「${e.data}」`)
  });
```

```diff:worker.js
+ const bc = new BroadcastChannel("WebSocketChannel");
  const ws = new WebSocket("ws://localhost:8001");

  ws.addEventListener("open", e => {
      console.log("Socket Opne");
  });

  // サーバーからデータを受け取る
  ws.addEventListener("message", e => {
      console.log(`SharedWorkerでサーバーからデータ受信し、メインスクリプトへデータ送信 : 「${e.data}」`)
      // 開いている全ての複数ウィンドウ、複数タブへメッセージ送信
-     for (const connection of connections) {
-         connection.postMessage(e.data);
-     }
+     bc.postMessage(e.data);
  });


- let connections = [];
  self.addEventListener("connect", e => {
      const port = e.ports[0];


-     connections.push(port);

      port.addEventListener("message", function (e) {
          console.log(`SharedWorkerでメインスクリプトからデータ受信し、サーバーへデータ送信 : 「${e.data}」`)
          ws.send(e.data);
      });

      port.start(); // addEventListenerを使用する場合に必要、onmessageを使う場合は暗黙的に呼び出される
  })
```

こちらでも同じように動作することができました。

<img width="915" alt="Screen Shot 2019-11-24 at 0.07.37.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/1629e531-1901-8429-0ef4-1fd55025b9b9.png">

# 2. SSE (Server-Sent Event) でもやってみた

```server.js
const http = require("http");
const fs = require("fs");

// httpサーバー作成
const server = http.createServer();

server.on("request", async (req, res) => {
  if (req.url === "/events") {

    res.writeHead(200, {
        "Content-Type": "text/event-stream", 
        "Cache-Control": "no-cache",
    });

    setInterval(() => {
        res.write(`data: ${JSON.stringify({ time: new Date().toLocaleTimeString() })}\n\n`)
        res.flushHeaders();
    }, 1000)

  } else {

    try {
        res.write(await fs.promises.readFile(`${__dirname}${req.url === "/" ? "/index.html" : req.url}`));
    } catch(e) {
        console.log(e.message)
        res.writeHead(404)
    }
    res.end();

  }
});
server.listen(8000);
console.log("http server listening ...");
```

```index.html
<!DOCTYPE html>
<meta charset="UTF-8">
<title>Document</title>
<script src="event.js" defer></script>

<ul id="sample"></ul>
```

```event.js
const bc = new BroadcastChannel("WebSocketChannel");
const worker = new SharedWorker("worker.js")
worker.port.start();

bc.addEventListener("message", e => {
    sample.appendChild(document.createElement("li")).textContent = e.data;
});
```

```worker.js
const bc = new BroadcastChannel("WebSocketChannel");
const es = new EventSource("./events");

es.addEventListener("message", e => {
    const { time } = JSON.parse(e.data);
    bc.postMessage(`${time} from SharedWorker!`);
});
```

<img width="424" alt="Screen Shot 2019-11-24 at 0.55.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142910/698d6254-bfc2-9c43-943a-048a7b1b31bc.png">

SSEでもSharedWorkerから接続して、複数タブを開いても接続は1つだけにすることができました。

最後まで読んでいただいてありがとうございました。