Box UI ElementsのContent OpenWithでファイルの更新に反応してみた

この記事のシリーズ：

[Box UI ElementsのContent OpenWithでBox Editをつかってみた](https://qiita.com/kobay/items/8f01353308141813881f)
[Box UI ElementsのContent OpenWithでG Suiteを開いてみた](https://qiita.com/kobay/items/cfa15d894e17a0fba384)
[Box UI ElementsのContent OpenWithでファイルの更新に反応してみた](https://qiita.com/kobay/items/685a0cdf139ad896d43c) ←　この記事
[Box UI ElementsのContent OpenWithでファイルの更新に反応してみた クライアントサイド編](https://qiita.com/kobay/items/0169158c78447ffd2763)

コードは、[Github](https://github.com/kobay/box-openwith-example)でも確認いただけます。


## 前回の内容と課題

前回、[Box UI ElementsのContent OpenWithでBox Editをつかってみた](https://qiita.com/kobay/items/8f01353308141813881f) という記事の中で、Box UI ElementsのOpenWithを使って、カスタム画面から変更するというのを試したのですが、課題として変更した内容が標準のBOXのように検知できていませんでした。

@daichiiiiiii さんから、コメントをいただいて、BoxのLong Polling APIを利用すれば良いとおしえていただきました。ありがとうございます！

> 更新ボタン表示させるには、BoxのLong Polling APIを使用して更新情報を監視する必要があります。
> https://developer.box.com/guides/events/polling/
>
> イベントを検知したら、User Event APIからいま表示しているファイルの更新イベントが見つかったらPopupを出す。みたいなロジックを組み込む必要があります。
> 残念ながらUI Elementsには上記ロジック内包されていません。。。残念



今回は、このロングポーリングを利用して、変更を検知する操作を試したので、内容を残します。



## 試したこと概要



以下のような実装を追加しました。

* Herokuのアプリケーションから、Boxのロングポーリングにつなぎ、AppUserのイベントを購読するようにする。

* Herokuアプリとブラウザを、Websocketでつなぎ、Boxのイベントに更新を見つけたらブラウザに通知する。

* ブラウザは更新の通知を受けたら、Confirmのダイアログを開き、更新するか確認しYesの場合画面をリロードし、Noの場合は二度と更新を確認しない。



なお、`app.js`はシンプル版と、Heorku対応版を作りました。
利用しているHerokuの特性として、レスポンスが55秒かえってこないと自動的に接続を殺し、エラーにするという機能に対処する必要があるためです。
変更の通知が要件として大切な場合、Herokuのようなインフラを利用しない方がいいと思います。

改良の余地は多分に残っていますが、基本的な動きは確認できたのでコードを共有します。

コードは前回の記事のものをベースに使っています。

## 具体的な変更



### サーバー側の改造



wsモジュールを追加します。

```sh
yarn add ws
```

#### シンプル版

Boxのロングポーリングイベントを利用し、クライアント側ともWebsocketでつなぎます。
シンプル版です。Herokuだと55秒で動かなくなります。


```javascript:app_ws.js
const express = require("express");
const http = require("http");
const boxSDK = require("box-node-sdk");
const config = require("./config.json");
const WebSocket = require("ws");

const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });

app.set("views", ".");
app.set("view engine", "ejs");

const USER_ID = "12771965844";
const FILE_ID = "665319803554";

const sdk = boxSDK.getPreconfiguredInstance(config);
const auClient = sdk.getAppAuthClient("user", USER_ID);

app.get("/", async (req, res) => {
  // トークンをダウンスコープする。
  // ここでは、OpenWithで必要なものと、Previewで必要なものを両方スコープにいれてトークンをダウンスコープする
  const downToken = await auClient.exchangeToken(
    [
      "item_execute_integration",
      "item_readwrite",
      "item_preview",
      "root_readwrite",
    ],
    `https://api.box.com/2.0/folders/0`
  );

  // テンプレートにパラメータを渡して、HTMLを返す
  res.render("index", {
    fileId: FILE_ID,
    token: downToken.accessToken,
  });
});

// Boxで、ファイルが変更されたことを、ロングポーリングを使って検知し、フロントエンドに通知する。
// 必ずしもそうする必要は無いが、ここではブラウザとHeroku間をWebsocketでつないでいる。
// Websocketの中で、Heroku ⇔ Box APIを、Long Pollingでつなぐ。
// ブラウザ ⇔ （Websocket）⇔ Heroku App ⇔ （Long Polling） ⇔ BOX API
wss.on("connection", async (ws) => {
  // ブラウザとHerokuの間のWebsocketのハンドリング

  // ロングポーリングはAppUserのトークンで行う必要がある
  const stream = await auClient.events.getEventStream();

  // ロングポーリングからデータを受け取ったときの処理
  stream.on("data", (event) => {
    // 更新されたことを、event_typeで判定（プレビューの場合などもイベントが来る）
    if (event.event_type && event.event_type === "ITEM_UPLOAD") {
      // クライアントに更新を通知。ここでは簡易的にupdatedという文字列を返している。
      wss.clients.forEach((client) => {
        client.send("updated");
      });
    }
  });
});

server.listen(process.env.PORT || 3000, () => {
  console.log(`express started on port ${server.address().port}`);
});

```

#### Heroku対応版

こちらは、Herokuで利用する場合のため、45秒でつなぎ直しとブラウザへのPing打ちをしています。

```javascript:app_ws_heroku.js
const express = require("express");
const http = require("http");
const boxSDK = require("box-node-sdk");
const config = require("./config.json");
const WebSocket = require("ws");

const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });

app.set("views", ".");
app.set("view engine", "ejs");

const USER_ID = "12771965844";
const FILE_ID = "665319803554";

const sdk = boxSDK.getPreconfiguredInstance(config);
const auClient = sdk.getAppAuthClient("user", USER_ID);

app.get("/", async (req, res) => {
  // トークンをダウンスコープする。
  // ここでは、OpenWithで必要なものと、Previewで必要なものを両方スコープにいれてトークンをダウンスコープする
  const downToken = await auClient.exchangeToken(
    [
      "item_execute_integration",
      "item_readwrite",
      "item_preview",
      "root_readwrite",
    ],
    `https://api.box.com/2.0/folders/0`
  );

  // テンプレートにパラメータを渡して、HTMLを返す
  res.render("index", {
    fileId: FILE_ID,
    token: downToken.accessToken,
  });
});

// Boxで、ファイルが変更されたことを、ロングポーリングを使って検知し、フロントエンドに通知する。
// 必ずしもそうする必要は無いが、ブラウザとHeroku間をWebsocketでつなぐ。
// Websocketの中で、Heroku ⇔ Box APIを、Long Pollingでつなぐ。
// 関係は、以下のようなイメージ
// ブラウザ <= (Websocket) => Heroku App <=(Long Polling)=> BOX API
wss.on("connection", async (ws) => {
  // ブラウザとHerokuの間のWebsocketのハンドリング
  // Herokuではロングポーリングは55秒で強制的に止められるので、setTimeoutをつかって45秒毎につなぎ直している。
  // 単純なポーティングよりマシだが、本当に更新検知が必要な場合は、インフラにHeorkuを使わないほうがいいと思う。
  let pollingTimer;
  async function longPolling() {
    // Boxのロングポーリングイベント監視
    // ロングポーリングはAppUserのトークンで行う必要がある
    const stream = await auClient.events.getEventStream();

    // ロングポーリングからデータを受け取ったときの処理
    stream.on("data", (event) => {
      // 更新されたことを、event_typeで判定（プレビューの場合などもイベントが来る）
      if (event.event_type && event.event_type === "ITEM_UPLOAD") {
        // ここで本当ならfileId等もチェックしたほうがいいかも
        // クライアントに更新を通知。ここでは簡易的にupdatedという文字列を返している。
        wss.clients.forEach((client) => {
          client.send("updated");
        });
      }
    });

    // 45秒たったら、もう一度ロングポーリングをつなぎ直す
    pollingTimer = setTimeout(longPolling, 45000);
  }
  // setTimeoutは初回は即座に実行されないので、初回分だけ実行しておく。
  longPolling();

  // ブラウザとHerokuの間のWSも、Herokuは55秒でシャットダウンしてしまうので、pingだけ飛ばしておく。
  // Heorkuのようなインフラを使わないのであれば不要
  const pingTimer = setInterval(() => {
    wss.clients.forEach((client) => {
      client.send("ping");
    });
  }, 45000);

  // ブラウザが閉じられたとき、無駄な再接続を止める
  ws.on("close", () => {
    // ブラウザへのpingを止める
    clearInterval(pingTimer);
    // Boxへのロングポーリングを止める
    clearTimeout(pollingTimer);
  });
});

server.listen(process.env.PORT || 3000, () => {
  console.log(`express started on port ${server.address().port}`);
});

```





### クライアント側の改造

Heroku側とWebsocketでつなぎ、更新があったとき反応するようにします。
変更があった場合、簡易的にConfirmでリフレッシュの要否確認し、previewオブジェクトを作り直してpreview部分のみ再描画しています。

```html:index_ws.ejs

<!DOCTYPE html>
<html lang="en-US">
<head>
    <meta charset="utf-8" />
    <title>Sample</title>

    <link href="https://cdn01.boxcdn.net/platform/elements/11.0.2/ja-JP/openwith.css" rel="stylesheet" type="text/css"></link>
    <link href="https://cdn01.boxcdn.net/platform/preview/2.34.0/ja-JP/preview.css" rel="stylesheet" type="text/css"></link>

    <script src="https://cdn.polyfill.io/v2/polyfill.min.js?features=es6,Intl"></script>
    <script src="https://cdn01.boxcdn.net/polyfills/core-js/2.5.3/core.min.js"></script>

    <script src="https://cdn01.boxcdn.net/platform/elements/11.0.2/ja-JP/openwith.js"></script>
    <script src="https://cdn01.boxcdn.net/platform/preview/2.34.0/ja-JP/preview.js"></script>

    <style>
        .openwith-container {
            margin-left: 250px;
        }
        .preview-container {
            height: 800px;
            width: 100%;
        }
    </style>

</head>
<body>
    <h3>File Id: <%= fileId %></h3>

    <div id="container">
        <div class="openwith-container"></div>
        <div class="preview-container"></div>
    </div>

    <script>
        // app.jsから渡されたパラメータ
        const fileId = "<%= fileId %>"
        const token = "<%= token %>"

        const openWith = new Box.ContentOpenWith();
        openWith.show(fileId, token,  { container: ".openwith-container"})

        let preview = new Box.Preview();
        preview.show(fileId, token, { container: ".preview-container", autoFocus: false });

        // ファイルの更新に反応するため、Websocketを利用する
        const host = location.origin.replace(/^http/, "ws"); // -> wss://xxx.herokuapp.com/
        const ws = new WebSocket(host);

        let confirmed = null
        ws.onmessage = event => {
            // Herokuからメッセージが来たとき。
            // updatedであれば、簡易的にconfirmウィンドウを出す。
            // OKを押したら、簡易的にリロードして再読み込みし、変更を反映。
            // OK, Cancelの確認は一度だけ聞く。
            if(event.data === "updated" && confirmed !== false) {
                if(confirmed === null) {
                    confirmed = confirm("refresh?")
                }
                if(confirmed) {
                    // previewだけを描画し直す。
                    // preview.hide() // hide()はしてもしなくてもすぐには見た目変わらず。
                    // preview.show(..) // 単純にshowを再度呼んだだけでは画面が更新されない。
                    preview = new Box.Preview(); // preivewオブジェクトは再利用できないっぽい。再度newする必要があるみたい。
                    // 毎回プレビューの位置までスクロールされたくないのでautoFocus:false
                    preview.show(fileId, token, { container: ".preview-container", autoFocus: false });
                }
            }
        }
    </script>
</body>
</html>


```



## 試してみる


「開く」ボタンを押して、ローカルでWordを立ち上げ、保存をして数秒でConfirmが表示されるのが確認できました。Yesを押すとリロードしてPreviewに最新のデータが反映されました。


![スクリーンショット 2020-05-16 13.33.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45767/3047c16c-7e7e-fbc5-00a8-7049b5081b38.png)



## まとめ

@daichiiiiiii さんから教えていただいた方法でうまく行きました。

Herokuの制限のため再接続している点が微妙です。
本気で更新の通知を実装する場合、この点で制限が無いインフラを選定すべきだと思います。