ホワイトボードを共有し、複数端末で手描きやスタンプをリアルタイム同期する Web サービス hakuban4 を作成しました。
背景
我が家では、もう10年以上家庭内で買い物メモやちょっとした予定を自作のWebホワイトボードで共有しています。冷蔵庫のドアに古くなったiPadを貼り付けて、買い物リストなどを書き込み、買い物先からはスマホで参照します。今では、この仕組みのない生活が考えられないほど日々活用しています。
当初はUIコンポーネントを使わず、JSP+JavaScriptでゴリゴリと実装していました。その後Vue2+TypeScriptで書き換えましたが、自前の描画コンポーネントの構造は引き継ぎました。常時アクティブなEC2インスタンス上でSpring Bootアプリケーションサーバが動いていて、HTTPポーリングで更新確認していました。認証も行っておらず、URLとトークンが分かれば誰でもアクセス可能でした。
今回は、これら「負の遺産」を一掃する開発です。
クライアントの描画を Vue3 と vue-konva に置き換え、同期と永続化は API Gateway WebSocket・Lambda・DynamoDBを使い、配信は S3 と CloudFront、インフラ定義は AWS CDK を使います。IDPにはGoogle 認証を採用します。
描画そのものは、線の太さ3種、色8種、矩形削除、全削除、左上詰め、スタンプ配置を揃えました。あわせて画面上部にゴミ収集予定を出し、生活用のメモ板として一画面で完結するようにしています。スマホではボード全体を縮小表示し、描画操作は難しくても閲覧はできる、という割り切りです。
リアルタイム共有は、ページを開いたら WebSocket を張り、サーバから現在の描画を取得し、誰かが更新したら差分を保存して接続中のクライアントへ配信する流れにしました。静的サイトとサーバレスの組み合わせで、常時起動のアプリサーバを持たない構成に変更します。
vue-konva とホワイトボード描画
フロントエンドの描画には vue-konva(Konva)を使っています。Konva は React で広く使われている 2D グラフィクス用 JavaScript フレームワークですが、Vue3 や TypeScript も公式にサポートしています。キャンバスの論理サイズは 1200×800 で、線は stroke、スタンプは stamp という2種類の要素として扱います。
| 要素 | 主な属性 |
|---|---|
| stroke |
points(x,y の平坦配列)、stroke、strokeWidth
|
| stamp |
stampId、x、y、width、height
|
操作は次のとおりです。
- 手描き(細・中・太、黒/灰色/青紫/青/緑/黄緑/オレンジ/赤)
- SVG スタンプの配置(マニフェストで追加可能)
- 矩形範囲の削除(線は全頂点、スタンプは四隅が矩形内に入った場合のみ)
- 全削除
- 左上詰め(全要素の上端・左端を算出し、マージン位置へオフセット)
- Undo / Redo(端末ローカルの履歴)
アーキテクチャ全体像
論理構成は次のとおりです。
ブラウザは CloudFront 経由で静的アセットを取得し、WebSocket 接続時にクエリへ token と boardId を付けます。$connect は Lambda Authorizer で Google ID トークンまたは自前セッショントークンを検証し、許可メール一覧に含まれる場合のみ接続を通します。接続後にクライアントが sync を送り、サーバが snapshot を返します。描画変更は update で受け、DynamoDB へ保存したうえで同一ボードの接続へ patch を配信します。
AWS リソースは CDK で定義しています。Lambda は Node.js 20、DynamoDB はオンデマンド課金、接続テーブルには TTL、フロントは非公開 S3 と Origin Access Control 付き CloudFront です。SPA 向けに 403/404 を index.html へフォールバックし、ルーティングは静的配信でも動く Hash モードにしています。
ボード状態と更新プロトコル
サーバ・クライアントで共有するボード状態は、おおむね次の形です。
type BoardState = {
boardId: string;
elements: BoardElement[];
updatedAt: string;
revision: number;
};
更新はフル置き換えではなく、次のオペレーションで差分を送ります。
| op | 意味 |
|---|---|
add |
要素を追加(線の確定、スタンプ配置など) |
remove |
要素 ID 列で削除(矩形削除など) |
clear |
全削除 |
set |
要素配列の置き換え(左上詰め、Undo/Redo の反映など) |
クライアントは送信前にローカルへ適用する楽観更新を行います。サーバは現行状態を読み、パッチを適用して revision を1増やし、保存後に接続へ配信します。現状の保存は条件付き書き込みではなく、読み取り後の全体上書きです。同時編集が激しい用途ではなく、少人数の生活メモを想定した選択です。
クライアント側では revision で次のように整理しています。
- 受け取った snapshot/patch の
revisionがローカルより古い場合は無視する - 自分の楽観更新と同じ
revisionの echo では、要素を再適用せずupdatedAtだけ合わせる
これにより、描画直後に遅れて届く古い snapshot で線が消える、といったちらつきを抑えられます。なお API Gateway の $connect 中は postToConnection が使えないため、初期状態の取得は接続確立後の sync に分離しています。
認証とセッショントークン
IDP には Google を使い、Gmail アカウントの ID トークンを検証します。Issuer と Audience(クライアント ID)に加え、環境変数で与える許可メールアドレス一覧で認可しています。ロール制御はなく、一覧に入っていれば全操作が可能です。
毎回 Google 再認証を求めると手間が大きいため、初回の sync 成功時に自前のセッショントークンを発行し、クライアントへ返しています。ブラウザ側は localStorage に保存し、次回以降の接続に使います。このトークンは exp を持たない無期限 JWT で、署名鍵は Secrets Manager に置いています。端末紛失などで一括失効したいときは、再デプロイせず Secret の値を更新します。Lambda 側は鍵を最大5分キャッシュし、ローテーションを反映しつつ Secrets Manager 呼び出しを抑えます。
接続レコードには connectionId、boardId、メール、TTL(既定24時間)を持ち、boardId の GSI で配信先を引きます。切断済み接続へ postToConnection したときの 410 Gone は検知して接続レコードを削除します。
生活用途向け UI
週間カレンダーとゴミ収集
画面上端に、今日を含む週(日曜始まり)の日付・曜日とゴミ収集ラベルを出します。設定は静的 JSON で差し替え可能です。曜日ごとの収集に加え、「月第 N 回目の M 曜日」も表現できます。
{
"timezone": "Asia/Tokyo",
"weekly": [
{ "weekday": 1, "label": "瓶/缶/ペット" },
{ "weekday": 3, "label": "普通/ミックス/紙" },
{ "weekday": 4, "label": "プラ" },
{ "weekday": 6, "label": "普通" }
],
"nthWeekday": [
{ "weekday": 3, "nth": [1, 3], "label": "粗大/金属" }
]
}
「今日」の枠は強調表示し、日付の切り替えは毎分のチェックで追従します。
左上詰め
買い物リストのように、消したあとに余白が広がる使い方を想定し、全描画要素のバウンディングから上端・左端を求め、キャンバス左上へまとめてずらす操作を用意しました。サーバへは set で新しい要素配列を送ります。
PWA と小さい画面
vite-plugin-pwa でマニフェストとプリキャッシュを入れ、ホーム画面追加に耐える形にしています。小さい画面では論理キャンバスをウィンドウに合わせて縮小表示します。
実装で工夫した点
Konva の一点線と異常座標
線の始点だけだと Konva の Line が不安定になることがあるため、描画開始時に同じ座標を2点分入れています。確定時に点が足りない場合は、わずかにずらした2点目を補って点として見えるようにしています。また、ポインタ初期化の失敗で先頭に (0,0) が混入するケースがあり、2点目が原点付近でないときだけ先頭を落とす補正を入れました。
画面外での pointer up
キャンバス外でマウスやタッチを離すと、Stage 内のイベントだけでは線が確定しないことがあります。window の mouseup/touchend/移動イベントを購読し、描画中なら確定、矩形削除中なら範囲確定、といった処理に回しています。
Undo/Redo と Vue の Proxy
履歴は端末ローカルです。Vue のリアクティブ Proxy は structuredClone できないため、toRaw したうえで JSON 経由で複製しています。Undo/Redo の結果はボードへ set として送り、他端末にも反映します。
Safari 12 / iOS 12
利用端末に古い iOS Safari が残る想定で、@vitejs/plugin-legacy のターゲットに iOS 12/Safari 12 を含め、modern バンドル側の構文レベルも下げています。コード上でも globalThis や CSS の inset など、当該環境で使えない API を避けています。
WebSocket の echo と遅延 snapshot
楽観更新のあとに自分宛ての patch や、遅れて届く snapshot が来ます。revision の大小比較と、同一 revision の echo では要素を再適用しない、という単純な規則で見た目の安定を取りました。サーバ側の同時更新制御を厳密にする代わりに、クライアント側で「古いものは捨てる」方針です。
開発の流れ
工程は要件整理、構成設計、実装と単体テスト、結合確認の順で進めました。パッケージはフロントエンド、バックエンド、インフラに分けています。
| 観点 | 内容 |
|---|---|
| フロント | Vue 3、TypeScript、Vite、vue-konva、vue-router、PWA、legacy ビルド |
| バックエンド | TypeScript Lambda、jose、AWS SDK(DynamoDB/API Gateway Management/Secrets Manager) |
| インフラ | AWS CDK(WebSocket API、DynamoDB、S3、CloudFront、Secrets Manager) |
| 検証 | Vitest(フロントは jsdom、バックエンドは Node)。パッチ適用、座標補正、ゴミ収集日、JWT、revision 更新フローなどを単体テスト |
インフラの自動テストやブラウザ E2E、実 AWS を叩く結合テストは本稿時点では自動実行の範囲外とし、デプロイ後の手動確認で補っています。
成果物
まとめ
- UUID 単位の共有ホワイトボードを、Vue/vue-konva と API Gateway WebSocket・Lambda・DynamoDB で実装しました。
- 更新は
add/remove/clear/setのパッチとrevisionによる楽観更新で、少人数利用向けに単純な永続化と配信を選びました。 - Google ID トークンと許可メール一覧で入り口を絞り、無期限セッショントークンと Secrets Manager の鍵ローテーションで再認証の手間と失効手段を両立しています。
- ゴミ収集カレンダー、左上詰め、スタンプ、PWA、Safari 12 向けビルドなど、生活用メモ板としての周辺機能を同じ画面に載せています。
- Konva の一点線、画面外 pointer up、410 Gone の接続掃除、Vue Proxy の履歴複製など、描画とリアルタイム同期でつまずきやすい箇所を実装側で吸収しました。

