同じURLのWebページの２重起動制御の実装

先日、匿名の待ち合わせを手助けするサービスを個人開発しました。

[アノMeet](https://anomeet.info)

それの宣伝と、実装する際に起きた問題と対応、およびより良い方法がないかの問いかけです。

# 要件概要

- 同じURLを同じブラウザの別タブ(別Window含む)で２重に起動できないようにする
- ２重に起動しようとした場合には、（２重起動できない旨を示した）エラーページに飛ばす
- ブラウザが違う場合や、シークレットモードで起動した場合には、２重起動と判定しなくてもよい

## 要件の理由

作成したWebサービスは以下のようなURLの構成にしています。

```
https://ホスト名/m/{meetingId}
``` 

ページ毎に入室を行い、ユーザを識別するので、その際に認証トークンを発行しています。
何らかの理由で、ページのリロードが発生しても、再度同じユーザとして識別されるようにするため、認証情報をブラウザのストレージ（今回はクッキーに保存）で以下のように保持しています。

```
document.cookie = `token=${認証情報}; path=/m/${meetingId}; expires=${1時間}`;
```
画面が起動した際に、ブラウザのストレージから認証情報を取り出して、あればそれを使い認証。無いまたは認証に失敗した場合には、入室画面を表示するようにしていました。

そのため、同じURLのWebページが表示されると、同じ認証情報で２画面から操作されてしまうので、２重起動の制御が必要でした。

## 既知の対応案がないかの調査
何かしらすでにライブラリなどあれば、それを組み込んだほうが工数が少ないので、調査してみました。

[Stop people having my website loaded on multiple tabs](https://stackoverflow.com/questions/11008177/stop-people-having-my-website-loaded-on-multiple-tabs/45717724)
これが一番近そうでした。ライブラリなどは特に見当たらなかったですが、対応案が記載されていました。こちらの内容はとても参考になりました。

## 対応案検討

タブ間で何かしらの通信を行う方法として以下の方法を考えました。

1. 独自のサーバを通して通信を行う
1. [LocalStorage](https://developer.mozilla.org/ja/docs/Web/API/Window/localStorage)経由で通信を行う
1. [ServiceWorker](https://developer.mozilla.org/ja/docs/Web/API/ServiceWorker)経由で通信を使う
1. [BroadcastChannel](https://developer.mozilla.org/ja/docs/Web/API/BroadcastChannel)経由で通信を行う

### 独自のサーバを通して通信を行う
自前で用意しているサーバに対して、WebSocketやポーリングをして、常時接続しておき、すでに同じブラウザで起動しているかを判別する方法を考えました。
ただ、今回はこの方法は以下の理由により不採用としました。

- 同じブラウザの判別の仕方があいまい。IPアドレスではプロキシ経由だと無理だろう。ローカルストレージなどに個別のIDを持っていくのかもしれないが、
- わざわざサーバまでいくとコストが大きいため、同じURLかの判別に時間がかかる

### LocalStorage経由で通信を行う
画面起動後にローカルストレージに対して書き込みを行い、すでに起動済みのタブで書き込みをリッスンしておく、同じURLであれば何かしらのエラーを返す。
この方法も不採用としました。

- ローカルストレージでは削除するタイミングが難しい。（ローカルストレージでなくても、セッションストレージでならセッション切れ時に削除されるが）。
- ローカルストレージまたはセッションストレージは本来情報を保持するためのものであり、通信するためのものではない。
 - 新たにストレージに情報を保持しなければならない案件があった際に、この通信用のデータが邪魔しないようにしないといけないのが手間である

### ServiceWorker経由で通信を行う
ServiceWorkerの私の理解は主に以下の機能があります。とても強力な機能を持っていると思います。

- Webへのリクエスト/レスポンスにProxyとして割り込みができる
- 同一オリジン間で情報が共有される(同一タブで利用可能)
- ServiceWorkerから各ページに対してメッセージ送信が可能

今回のケースでは、画面起動後にダミーのリクエストを行い、ServiceWorker経由で別タブにメッセージを送信。別タブのほうでは、自身のURLと同じかどうかを判別し、同じURLならばServiceWorker経由でメッセージを送信。

この方法でもよいかなと思いましたが、BroadcastChannelと比較し、以下の理由で不採用としました。

- 高機能なため、ServiceWorkerのインストールなど手間がかかる
- ServiceWorker内の処理は既存の実行領域とは別に動くので、既存の処理をそのまま使えない

### BroadcastChannel経由で通信を行う
BroadcastChannelでは、同一オリジン間で情報を共有するためのチャネルです。これで、シンプルに要件を満たせると思いこの方法を採用しました。

## BroadcastChannelを利用した同じURLの２重起動制御の実装
実装イメージは以下のように行います。（タブA：新たに起動したタブ、タブB：すでに起動済みのタブ）

1. タブA：起動時にタブ毎にユニークなIDを生成する
1. タブA：起動後に、BroadcastChannelに対して、現在のWebページのURLとタブ毎のIDをつけて送る
1. タブB：すでに起動済みのタブはメッセージを受け取り、送られてきたURLが自身のURLと同じものかどうか判別する
1. タブB：同じURLだった場合には、以下の行動を行う
 1. 受信側としての同じURLとしての挙動を行う（ファビコンを目出せるなど）
 1. BroadcastChannelに対して、対象のタブIDに対して、同じURLである旨のメッセージを送る
1. タブA：タブBからすでに同じURLで起動している旨のメッセージが送られたので、送信側としての同じURLとしての挙動を行う（エラーページに飛ばせる）

```javascript
// チャネル名
const channelName = 'PreventSameUrl';


const MESSAGE_TYPE = {
  SAME_PATHNAME: "SAME_PATHNAME",
  ENTER: "ENTER",
}
const SEND_TYPE = {
  BROADCAST: "BROADCAST",
  UNICAST: "UNICAST",
}

export default class PreventSameUrl {
  constructor({ doSamePathnameOld = defaultParam.doSamePathnameOld,
    doSamePathnameNew = defaultParam.doSamePathnameNew,
    getTargetPath = defaultParam.getTargetPath,
    isSamePathname = defaultParam.isSamePathname
  }) {
    // 変更がありそうな処理をパラメータで渡せるようにする
    this.doSamePathnameOld = doSamePathnameOld.bind(this)
    this.doSamePathnameNew = doSamePathnameNew.bind(this)
    this.isSamePathname = isSamePathname.bind(this)
    this.getTargetPath = getTargetPath.bind(this)

    this.broadcast = new BroadcastChannel(channelName);
    // タブ毎のユニークなIDの生成
    this.clientChannelId = uuidv4()
    // このタブのURLとしてパスを取得する　クエリパラメータを含めて比較するのかなどはこの関数で決める
    this.targetpath = this.getTargetPath()
    // メッセージ受信の定義
    this.broadcast.onmessage = (e) => {
      const message = JSON.parse(e.data);
      // IDが違う場合のメッセージ受信は無視
      if (message.sendType === SEND_TYPE.UNICAST && message.targetClientChannelId !== this.clientChannelId) {
        return
      }
      if (message.messageType === MESSAGE_TYPE.ENTER) {
        this.reciveEnter(message, e)
      } else if (message.messageType === MESSAGE_TYPE.SAME_PATHNAME) {
        this.reciveSamePathname(message, e)
      }
    }
  }
  // タブA：起動後に、BroadcastChannelに対して、現在のWebページのURLとタブ毎のIDをつけて送る
  sendEnter = () => {
    const newMessage = {
      messageType: MESSAGE_TYPE.ENTER,
      sendType: SEND_TYPE.BROADCAST,
      clientChannelId: this.clientChannelId,
      targetpath: this.targetpath,
    }
    this.broadcast.postMessage(JSON.stringify(newMessage));
  }
  // タブB：すでに起動済みのタブはメッセージを受け取り、送られてきたURLが自身のURLと同じものかどうか判別する
  reciveEnter = (message, e) => {
    if (this.isSamePathname(this.targetpath, message.targetpath, message)) {
      const newMessage = {
        messageType: MESSAGE_TYPE.SAME_PATHNAME,
        sendType: SEND_TYPE.UNICAST,
        clientChannelId: this.clientChannelId,
        targetClientChannelId: message.clientChannelId
      }
      this.broadcast.postMessage(JSON.stringify(newMessage));
      this.doSamePathnameOld(message, e)
    }
  }
  // タブA：タブBからすでに同じURLで起動している旨のメッセージが送られたので、送信側としての同じURLとしての挙動を行う
  reciveSamePathname = (message, e) => {
    this.doSamePathnameNew(message, e)
  }
  destroy = () => {
    this.broadcast.close()
  }
  initialize = () => {
    this.sendEnter()
  }
}


/**
 * クライアント毎のユニークなIDを生成
 */
function uuidv4() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
    var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}
/**
 * 引数のデフォルト値
 */
const defaultParam = {
  /**
   * 現在のページに対して、比較対象となるパスを取得する
   */
  getTargetPath: function(){
    return location.pathname 
  },
  /**
   * 引数で渡されたそれぞれのパスが同じものかどうかを判定する
   * @param {String} thisPathname 
   * @param {String} otherPathname 
   * @param {*} message 
   */
  isSamePathname: function(thisPathname, otherPathname, message) {
    return (thisPathname === otherPathname)
  },
  /**
   * 同じパスだった場合に実行する操作
   * これは、後から同じURLになったものに対して行う操作です
   * @param {*} message 
   * @param {*} e 
   */
  doSamePathnameNew: function(message, e){
    window.location.href = "エラーページ"
  },
  /**
   * 同じパスだった場合に実行する操作
   * これは、もとから同じURLになったものに対して行う操作です
   * @param {*} message 
   * @param {*} e 
   */
  doSamePathnameOld: function(message, e){
    // ファビコンを目出させる
  }
}
```

## 実装結果
こちらの実装で、同一のWebページが起動されないように制御することができました。getTargetPath()の呼び出しがコンストラクタではなく、メッセージ受信時にしたほうが良いかなと思いましたが、制御したいのはこのページだけなので、このページから離れたらdestroy()を呼び出すようにしているので、こちらで問題ないと思っています。

また、コンストラクタ呼び出しから、同じURLと判別しエラーページに飛ばすところまでの時間は300ミリ秒でした。思ったよりも、かかってしまっていたのは驚きですが、起動時にほかに各種処理があったので、仕方ないのかなと感じています。
もっとより良い方法をご存じの方がいれば教えていただけると助かります。