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でファイルの更新に反応してみた](https://qiita.com/kobay/items/685a0cdf139ad896d43c) という記事の中で、Box UI ElementsのOpenWithを使って、カスタム画面から変更した際に、サーバーサイドでロングポーリングをおこない、変更を検知して再描画するという内容を書きました。


@daichiiiiiii さんから、再度コメント以下のようなコメントいただきました。ありがとうございます！ 

> BoxのWebアプリの場合、ClientサイドでLong PollingとEvent logチェックしています。
同じAppUserを複数ユーザが使う場合には、データ流出等につながる可能性があるので適しませんが、1:1(AppUser:実User)の場合であればクライアントサイドに実装しても良い気もします。

確かにアクセストークンが漏れても構わない場合、クライアントサイドで行ったほうが効率が良さそうです。
とはいえ、OpenWithやPreviewを表示するためにすでにダウンスコープしたアクセストークンを露出させています。
同じアクセストークンでEventを購読できるなら問題なさそうです。→　同じトークンでEventを取得できます。
というわけで、早速こちらもためしてみました。


## 変えたところ

変更の検知をクライアントサイドに寄せるので、再びサーバー側のロジックはシンプルなものになります。

```javascript:app_client_long_polling.js
const express = require("express");
const boxSDK = require("box-node-sdk");
const config = require("./config.js");

const app = express();

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

/**
 * setup.jsで作成したファイルとユーザー
 */

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

app.get("/", async (req, res) => {
  try {
    const sdk = boxSDK.getPreconfiguredInstance(config);
    // AppUserの権限でClientオブジェクトを作成
    const auClient = sdk.getAppAuthClient("user", USER_ID);

    // トークンをダウンスコープする
    // APIリファレンスには載っていないが、UI Elementsの説明には書いてあるAPI
    // ここでは、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_client_long_polling", {
      fileId: FILE_ID,
      token: downToken.accessToken,
    });
  } catch (e) {
    console.error(e.toString());
  }
});

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

```
 
次に、クライアントコードでイベントを購読するようにします。


```html:index_client_long_polling.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 });


    openWith.addListener("execute", async () => {
        // openWithが開かれたので、ロングポーリング開始

        // リアルタイムサーバーのURLを取得
        const optionsRes = await fetch("https://api.box.com/2.0/events", {
            method: "OPTIONS",
            headers: {
                "Content-Type": "application/json; charset=utf-8",
                "Authorization": `Bearer ${token}`,
            }
        });
        const optionsJRes = await optionsRes.json();

        let lastSequenceId = 0; // 最後のSequenceID
        const subscribe = async (streamPosition = "now") => {
            // リアルタイムサーバーに対してロングポーリングを行う
            // CORSエラーを避けるため、シンプルなリクエストを使う
            let rtsRes = await fetch(optionsJRes.entries[0].url, {
                method: "GET",
                mode: "no-cors",
                headers: {
                    "Content-Type": "application/x-www-form-urlencoded"
                }
            })

            // ロングポーリングからはtype: "opaque"というのが帰ってくるのだけど、、よくわからない・・・。
            // とはいえ、応答が帰ってくると、何かのイベントが発生したことはわかる。
            // console.log("rtServer res", rtsRes)
            　
            // 何かのイベントが発生したので、EventAPIをGETで叩き、詳細情報を取り出す。
            // 上のロングポーリングだけに反応して再描画すると、再描画のプレビューイベントを拾ってしまい、
            // 無限ループしてしまうので、イベントを更新に絞って確認する必要がある。
            const qs = new URLSearchParams();
            qs.set("event_type", "ITEM_UPLOAD"); // 更新だけに絞る。
            qs.set("stream_type", "sync");
            qs.set("stream_position", streamPosition); // 初回は"now", 2回め以降はnext_stream_positionが入る

            const getRes = await fetch(`https://api.box.com/2.0/events?${qs.toString()}`, {
                method: "GET",
                headers: {
                    "Content-Type": "application/json; charset=utf-8",
                    "Authorization": `Bearer ${token}`,
                }
            });
            const getJRes = await getRes.json();

            // 複数もどってくるイベントを、対象ファイルの更新のもので、最新のものに絞る
            const latestEvent = getJRes.entries.reduce((acc, cur) => {
                if(cur.event_type === "ITEM_UPLOAD"
                    && cur.source
                    && cur.source.type === "file"
                    && cur.source.id === fileId) {
                    if(!acc) {
                        return cur;
                    }
                    return (cur.source.sequence_id > acc.source.sequence_id) ? cur : acc;
                }
                return null;
            }, null)

            // 前回処理したイベントより進んでいるときだけリロードする
            // 初回にstreamPosition === "now"でイベントを取得すると、イベントが何も帰ってこない。
            // 対象のファイルの更新イベントじゃないかもしれないけどリフレッシュかける。ここはもうちょっとうまくやれそうな気もする。
            if(streamPosition === "now" || latestEvent && latestEvent.source.sequence_id > lastSequenceId) {

                //　今回処理するイベントのsequence_idを保存
                if(latestEvent) {
                    lastSequenceId = latestEvent.source.sequence_id;
                }

                // previewだけを描画し直す。
                preview = new Box.Preview();
                // 毎回プレビューの位置までスクロールされたくないのでautoFocus:false
                preview.show(fileId, token, { container: ".preview-container", autoFocus: false });
            }
            // 次のイベントへ
            await subscribe(getJRes.next_stream_position)
        }
        // イベントの購読開始
        await subscribe();
    })

</script>
</body>
</html>

```

## まとめ

コードはもうすこし改善の余地はありそうですが、とりあえずクライアントサイドからポーリングし、画面を再描画することができました。

クライアントサイドだけだと、SDKが利用できないので、少しコードを書くのが大変です。