低コストで忠実、かつアドレス指定可能なライブミラー
AI エージェントがブラウザを操作するとき、監督する人にはエージェントの動作をリアルタイムで確認できる画面が必要です。PhantomStream はピクセルではなく DOM そのものをストリーミングします。まずページをスタイルがインライン化されたスナップショットとして 1 度取得し、WeakMap で安定した識別子を割り当てます。その後、MutationObserver でページを監視し、ノード ID をキーとする小さな差分(add、rm、attr、text)だけを、ページ自身の描画タイミングに合わせてバッチ送信します。
その結果、フレームレートではなくページの実際の変更量に応じて帯域幅が増減するミラーになります。テキストはどの解像度でもネイティブに描画され、リモート操作は座標を推測せず、安定した ID で実際の要素を指定します。
PhantomStream は v0.9.9.1 "Phantom Stream" として FSB 内で始まり、自動ブラウジングセッションをダッシュボードにライブ表示する機能を担ってきました。このリポジトリでは、それを単体で使えるプラグアンドプレイのフレームワーク兼 SDK として公開し、FSB に再統合できます。また、関連研究論文の作業リポジトリでもあります。
ピクセル方式の課題
ライブ表示で真っ先に思い浮かぶ手段は動画です。WebRTC、CDP スクリーンキャスト、連続スクリーンショットはいずれもピクセルを送信します。 ピクセルは重いデータです。ページに変化がなくてもフレームごとに帯域幅を消費し、エンコードとデコードでレイテンシーが増え、結果は非可逆かつ解像度に縛られます。
さらに、ピクセルの中身は不透明です。監視者はエージェントが触れている要素を特定できず、ノードの強調や画面への注釈もできず、ページを確実に遠隔操作することもできません。動画ストリーム越しのリモート操作では、すでに古くなっているかもしれないフレーム上のピクセル座標をクリックするため、位置がずれると別の対象をクリックしてしまいます。
動画ではなく DOM ストリーミングを選ぶ理由
フレームではなく構造化された DOM をストリーミングすると、エージェント監督で重要となるあらゆる観点で、ミラーのコスト構造と機能が変わります。
4 段階のパイプライン
ページをキャプチャし、ホストが各メッセージを転送用に包み、リレーが複数のビューアーへ配信し、ビューアーが再構築して適用します。リモート操作は同じ経路を逆向きにたどります。
ページ
スナップショット:クローン、スタイルのインライン化、ノード ID の付与、その後 rAF 単位でバッチ化した差分
ホスト
LZ-string エンベロープ、セッション識別子の付与、ウォッチドッグ
リレー
WebSocket ファンアウト、上限 1 MiB、バックプレッシャー時に破棄
ビューアー
サンドボックス化 iframe、ID による差分適用、オーバーレイ、画面に合わせた縮尺
中核メカニズム
安定したノード識別
キャプチャ側が WeakMap<Element, string> で識別情報を管理し、スナップショットと add 操作に nodeIds サイドカーを付けて送信します。そのため、遅れて届いた差分もライブページを変更することなく、常に正しい要素へ適用されます。
厳選した計算済みスタイルの取得
300 個以上すべてではなく、視覚的な忠実度に関わる約 85 個の CSS プロパティを要素ごとにインライン化し、既定値は省略します。これにより、YouTube ページのシリアライズ時間は 45 秒から操作可能な水準まで短縮されました。
描画タイミングに同期した差分
変更を requestAnimationFrame ごとにまとめてフラッシュするため、ミラーはページの描画と同じタイミングで更新されます。
セッション識別
各メッセージには streamSessionId と snapshotId が含まれます。ビューアーは古いメッセージを拒否するため、前のページから遅れて届いた差分がミラーを壊すことはありません。
すぐに使える機能
キャプチャコアはプレーンな JavaScript で、コンテンツスクリプト、addInitScript、ブックマークレットとして挿入でき、実行時のビルド工程は不要です。キャプチャ、ビューアー、リレーはすべて小さなトランスポート境界を介して通信します。
安定したノード ID
差分とリモート操作は、座標や壊れやすいセレクターではなく、WeakMap の識別情報でノードを指定します。
厳選したスタイルのインライン化
要素ごとに忠実度に直結する約 85 個の CSS プロパティを扱い、既定値は省略するため、負荷の高いページでも操作性を保てます。
描画タイミングに同期した差分
実際の変更 1 件につきコンパクトな操作 1 つを生成し、ページ自身の更新頻度に合わせて requestAnimationFrame でフラッシュします。
容量制限に収まるスナップショット
画面外のサブツリーを要素単位で除外し、スナップショットをリレーのメッセージ単位の上限内に収めます。
二重ウォッチドッグ
キャプチャ側のタイマーとホスト側のアラームがそれぞれ独立して、フラッシュまたは新しいスナップショットを強制し、停止したストリームを復旧します。
サンドボックス化した描画
ビューアーは厳密に allow-same-origin のみを許可した iframe サンドボックス内で再構築します(allow-scripts は許可しません)。さらに CSP と解析後のスクラブを適用します。
プライバシーマスキング
blockSelector、maskTextSelector、マスク関数が、機密コンテンツをページ外へ出る前に秘匿化します。パスワードは常にマスクされます。
参照によるメディア取得
画像、動画、音声はビューアー側のブラウザが元の URL から読み込むため、メディアのバイト列がリレーを通過することはありません。
Playwright アダプター
既製のアダプターを使ってキャプチャコアを Playwright または CDP ページに組み込み、認可された逆方向のリモート操作を利用できます。
埋め込み時のセキュリティコントラクト
PhantomStream は、攻撃者の影響を受ける HTML を、構造上スクリプトを実行できない状態で描画します。シリアライズ時に危険な内容を除去するのは転送用クローンだけです。ライブページには一切触れません。
- サンドボックスは厳密に
allow-same-originのみ。禁止:allow-scripts on*ハンドラー、危険な URL スキーム、srcdoc、object/embedを除去- 非公開テキストとフォーム値を、転送前にキャプチャ側でマスク
- フェイルクローズのメディア取得:https のみ、プライベート範囲を拒否、no-referrer
サンドボックスの保証
キャプチャ、ミラー、リレー
PhantomStream は各段階に 1 つずつサブパスを公開します。ページコンテキストにキャプチャ、リモートコンテキストにビューアーを接続し、Node 上のリレーで両者のメッセージを配信します。
import { createCapture } from '@full-self-browsing/phantom-stream/capture';
import { createWebSocketTransport } from '@full-self-browsing/phantom-stream/transport/websocket';
const transport = createWebSocketTransport({
url: 'wss://relay.example.com/ws?room=ROOM&role=source',
role: 'source'
});
const capture = createCapture({
transport,
skipElement: (el) => el.id === 'my-own-overlay' // 自身のUIを除外
});
capture.start(); // 最初にスナップショットを取得し、その後は差分をストリーミングimport { createViewer } from '@full-self-browsing/phantom-stream/renderer';
import { createWebSocketTransport } from '@full-self-browsing/phantom-stream/transport/websocket';
const transport = createWebSocketTransport({
url: 'wss://relay.example.com/ws?room=ROOM&role=viewer',
role: 'viewer'
});
const viewer = createViewer({
container: document.getElementById('mirror'),
transport
});
viewer.on('state', (e) => console.log('ビューアーは', e.state));
// connecting | live | stale | disconnectedimport http from 'node:http';
import { createRelay, createWebSocketRelayBackend } from '@full-self-browsing/phantom-stream/relay';
const relay = createRelay(); // メッセージごとの上限は1 MiB、バックプレッシャー時はドロップ
const server = http.createServer();
createWebSocketRelayBackend({ server, relay, path: '/ws' });
server.listen(8787);
// クライアントは次を使って参加 ?room=<id>&role=source|viewerリポジトリから直接実行することもできます:npm run demo はソースタブをビューアータブへミラーし、npm run demo:playwright はページを操作しながらビューアーへミラーします。
ドキュメント
詳しい解説はソースと同じ場所にあります。各ドキュメントは単独で読めるように構成されています。