この記事のシリーズ:
Box UI ElementsのContent OpenWithでBox Editをつかってみた
Box UI ElementsのContent OpenWithでG Suiteを開いてみた
Box UI ElementsのContent OpenWithでファイルの更新に反応してみた ← この記事
Box UI ElementsのContent OpenWithでファイルの更新に反応してみた クライアントサイド編
コードは、Githubでも確認いただけます。
前回の内容と課題
前回、Box UI ElementsのContent OpenWithでBox Editをつかってみた という記事の中で、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モジュールを追加します。
yarn add ws
シンプル版
Boxのロングポーリングイベントを利用し、クライアント側ともWebsocketでつなぎます。
シンプル版です。Herokuだと55秒で動かなくなります。
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打ちをしています。
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部分のみ再描画しています。
<!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に最新のデータが反映されました。
まとめ
@daichiiiiiii さんから教えていただいた方法でうまく行きました。
Herokuの制限のため再接続している点が微妙です。
本気で更新の通知を実装する場合、この点で制限が無いインフラを選定すべきだと思います。