Google Apps Scriptで表示したサイドバー/ポップアップにドキュメントの変更をlong pollingでリアルタイム反映する

ニッチな小ネタです。
この記事では、Google Apps Script で表示したサイドバー/ポップアップに、ドキュメントの変更を long polling でリアルタイムに反映する一手法を示します。
調べてみると意外と同様のことをされている方の情報が見つからなかったので、この記事がどなたかの参考になれば幸いです。

## 前提知識
Google Apps Script では下記のようなコードでサイドバー/ポップアップを表示することができます。
（以下、Google Sheets での例）

```javascript:main.gs
function showSidebar() {
  var html = HtmlService.createHtmlOutputFromFile('Sidebar')
    .setTitle('My custom sidebar')
    .setWidth(300);
  SpreadsheetApp.getUi().showSidebar(html);
  // NOTE: ポップアップを表示する場合は showSidebar ではなく showModelessDialog を使う
}
```

```html:Sidebar.html
<!DOCTYPE html>
<html>
  <body>
    <p>Hello, world!</p>
  </body>
</html>
```

また、こうして表示したサイドバー/ポップアップからは、下記のように `google.script.run` を使って Google Apps Script 側の関数を呼び出すことができます。

```javascript:main.gs
function myFunction(name) {
  return `Hello, ${name}!`;
}
```

```html:Sidebar.html
<!DOCTYPE html>
<html>
  <body>
    <script>
      // Sidebar/Popup から Google Apps Script の関数 myFunction を呼び出す場合の例
      google.script.run
        .withSuccessHandler(data => console.log(data))
        .withFailureHandler(error => console.error(error))
        .myFunction('world');
    </script>
  </body>
</html>
```

ここで Sidebar.html のスクリプトはクライアント上で、main.gs の myFunction は Google Apps Script のサーバー上で実行されることになります。
またそのため、google.script.run による関数の呼び出しは非同期で行われ、その結果を withSuccessHandler/withFailureHandler で指定したコールバックで受け取るという形になります。
google.script.run で関数呼び出しを行う度に通信が発生することに留意してください。

## 本題
Google Apps Script で表示したサイドバー/ポップアップに、ドキュメントの変更をリアルタイムで反映する方法を考えます。

とりあえず真っ先に思いつくのは普通の polling です。すなわち、定期的に google.script.run から Google Apps Script の関数を呼び出し、ドキュメントの変更を確認するという方法です。
これで十分な場合も多いですが、必要なリアルタイム性の程度によっては通信の回数が増えすぎる懸念があります。

ある程度の高いリアルタイム性が必要な場合の他の案として、long polling の手法を使うことが考えられます。
long polling では、クライアントからサーバーへのリクエスト送信後、サーバーではクライアントに通知すべきデータがあるまでレスポンスの送信を遅延させ、通知すべきデータが生じてからレスポンスを返します。
これにより通信頻度を抑えつつ、高いリアルタイム性を実現できます。
この long polling を google.script.run で実装してみます。
（なお、サイドバーから Google Apps Script への問い合わせは long polling になりますが、Google Apps Script 側でのドキュメントの変更チェックは通常の polling で行っています）

```javascript:main.gs
const TIMEOUT = 30000; // タイムアウトまでの時間（ミリ秒）
const INTERVAL = 300; // ドキュメントの変更チェックの間隔（ミリ秒）

function getDataLongPoll(lastValue) {
  const range = SpreadsheetApp.getActive().getSheets()[0].getRange(1, 1);
  const start = Date.now();

  // 一定時間経過するか値が変わるまで sleep しつつループ
  while (Date.now() - start < TIMEOUT) {
    const value = range.getValue();
    if (value !== lastValue) {
      return { value, isTimeout: false };
    }

    Utilities.sleep(INTERVAL);

    // Google Sheets の場合は SpreadsheetApp.flush() を呼び出さないと、
    // Range#getValue() がキャッシュされた値を返すことがある
    SpreadsheetApp.flush();
  }

  return { isTimeout: true };
}
```

```html:Sidebar.html
<!DOCTYPE html>
<html>
  <body>
    <p id="value"></p>
    <script>
      function getDataLongPoll(lastValue) {
        google.script.run
          .withSuccessHandler(data => {
            if (data.isTimeout) {
              getDataLongPoll(lastValue);
              return;
            }

            document.getElementById('value').textContent = data.value;
            getDataLongPoll(data.value);
          })
          .withFailureHandler(error => {
            // ...エラー処理...
            console.log(error);
          })
          .getDataLongPoll(lastValue);
      }
    
      getDataLongPoll('');
    </script>
  </body>
</html>
```

`SpreadsheetApp.flush()` を呼び出さないと `getValue()` で取得する値が更新されないようだったので、その点だけ注意が必要です。

これで通信回数をさほど増やすことなく、ドキュメントの変更をリアルタイムにサイドバー/ポップアップに反映することができます。

なお公式のドキュメントによると、Google Apps Script では実行時間に6分の制限があるとのことです（2024/12/10 現在）
https://developers.google.com/apps-script/guides/services/quotas?hl=ja#current_limitations

実際に試したところでは30分まで実行することも可能でしたが、念のため TIMEOUT の値は6分未満の設定にしておくのがよいでしょう。