研究 · @full-self-browsing

PhantomStream.

DOM ネイティブのライブブラウザミラーリング。ピクセルではなく、構造化 DOM として本物のタブをストリーム。

PhantomStream は、スタイルをインライン化したスナップショットを 1 度送信し、その後は安定したノード ID で指定される小さな MutationObserver 差分だけを送信します。ビューアーは、画面ストリーミングよりはるかに少ない帯域幅で、ライブかつ意味的にアドレス指定でき、リモート操作も可能なページの複製を受け取ります。

npm install @full-self-browsing/phantom-stream
v0.9.9.1Plain JS · ESMNode >=18 MIT
概要

低コストで忠実、かつアドレス指定可能なライブミラー

AI エージェントがブラウザを操作するとき、監督する人にはエージェントの動作をリアルタイムで確認できる画面が必要です。PhantomStream はピクセルではなく DOM そのものをストリーミングします。まずページをスタイルがインライン化されたスナップショットとして 1 度取得し、WeakMap で安定した識別子を割り当てます。その後、MutationObserver でページを監視し、ノード ID をキーとする小さな差分(addrmattrtext)だけを、ページ自身の描画タイミングに合わせてバッチ送信します。

その結果、フレームレートではなくページの実際の変更量に応じて帯域幅が増減するミラーになります。テキストはどの解像度でもネイティブに描画され、リモート操作は座標を推測せず、安定した ID で実際の要素を指定します。

PhantomStreamv0.9.9.1 "Phantom Stream" として FSB 内で始まり、自動ブラウジングセッションをダッシュボードにライブ表示する機能を担ってきました。このリポジトリでは、それを単体で使えるプラグアンドプレイのフレームワーク兼 SDK として公開し、FSB に再統合できます。また、関連研究論文の作業リポジトリでもあります。

背景

ピクセル方式の課題

ライブ表示で真っ先に思い浮かぶ手段は動画です。WebRTCCDP スクリーンキャスト、連続スクリーンショットはいずれもピクセルを送信します。 ピクセルは重いデータです。ページに変化がなくてもフレームごとに帯域幅を消費し、エンコードとデコードでレイテンシーが増え、結果は非可逆かつ解像度に縛られます。

さらに、ピクセルの中身は不透明です。監視者はエージェントが触れている要素を特定できず、ノードの強調や画面への注釈もできず、ページを確実に遠隔操作することもできません。動画ストリーム越しのリモート操作では、すでに古くなっているかもしれないフレーム上のピクセル座標をクリックするため、位置がずれると別の対象をクリックしてしまいます。

比較

動画ではなく DOM ストリーミングを選ぶ理由

フレームではなく構造化された DOM をストリーミングすると、エージェント監督で重要となるあらゆる観点で、ミラーのコスト構造と機能が変わります。

動画 / スクリーンショット
PhantomStream
帯域幅
ページ内容に関係なくフレームを連続送信
最初に 1 つのスナップショットを送り、以後はページ変更時に小さな差分だけを送信
レイテンシー
フレームごとにエンコード、送信、デコード
テキストの変更は小さな JSON 操作ひとつです
忠実度
非可逆で解像度に依存
忠実な DOM、ネイティブなテキスト描画、解像度に非依存
リモート操作
古い可能性があるフレーム上のピクセル座標
安定したノード ID で実際の要素を指定
検査性
不透明なピクセル
ミラー自体が DOM なので、クエリ、強調表示、注釈が可能
アーキテクチャ

4 段階のパイプライン

ページをキャプチャし、ホストが各メッセージを転送用に包み、リレーが複数のビューアーへ配信し、ビューアーが再構築して適用します。リモート操作は同じ経路を逆向きにたどります。

ページ

スナップショット:クローン、スタイルのインライン化、ノード ID の付与、その後 rAF 単位でバッチ化した差分

ホスト

LZ-string エンベロープ、セッション識別子の付与、ウォッチドッグ

リレー

WebSocket ファンアウト、上限 1 MiB、バックプレッシャー時に破棄

ビューアー

サンドボックス化 iframe、ID による差分適用、オーバーレイ、画面に合わせた縮尺

中核メカニズム

安定したノード識別

キャプチャ側が WeakMap<Element, string> で識別情報を管理し、スナップショットと add 操作に nodeIds サイドカーを付けて送信します。そのため、遅れて届いた差分もライブページを変更することなく、常に正しい要素へ適用されます。

厳選した計算済みスタイルの取得

300 個以上すべてではなく、視覚的な忠実度に関わる約 85 個の CSS プロパティを要素ごとにインライン化し、既定値は省略します。これにより、YouTube ページのシリアライズ時間は 45 秒から操作可能な水準まで短縮されました。

描画タイミングに同期した差分

変更を requestAnimationFrame ごとにまとめてフラッシュするため、ミラーはページの描画と同じタイミングで更新されます。

セッション識別

各メッセージには streamSessionIdsnapshotId が含まれます。ビューアーは古いメッセージを拒否するため、前のページから遅れて届いた差分がミラーを壊すことはありません。

機能

すぐに使える機能

キャプチャコアはプレーンな JavaScript で、コンテンツスクリプト、addInitScript、ブックマークレットとして挿入でき、実行時のビルド工程は不要です。キャプチャ、ビューアー、リレーはすべて小さなトランスポート境界を介して通信します。

安定したノード ID

差分とリモート操作は、座標や壊れやすいセレクターではなく、WeakMap の識別情報でノードを指定します。

厳選したスタイルのインライン化

要素ごとに忠実度に直結する約 85 個の CSS プロパティを扱い、既定値は省略するため、負荷の高いページでも操作性を保てます。

描画タイミングに同期した差分

実際の変更 1 件につきコンパクトな操作 1 つを生成し、ページ自身の更新頻度に合わせて requestAnimationFrame でフラッシュします。

容量制限に収まるスナップショット

画面外のサブツリーを要素単位で除外し、スナップショットをリレーのメッセージ単位の上限内に収めます。

二重ウォッチドッグ

キャプチャ側のタイマーとホスト側のアラームがそれぞれ独立して、フラッシュまたは新しいスナップショットを強制し、停止したストリームを復旧します。

サンドボックス化した描画

ビューアーは厳密に allow-same-origin のみを許可した iframe サンドボックス内で再構築します(allow-scripts は許可しません)。さらに CSP と解析後のスクラブを適用します。

プライバシーマスキング

blockSelectormaskTextSelector、マスク関数が、機密コンテンツをページ外へ出る前に秘匿化します。パスワードは常にマスクされます。

参照によるメディア取得

画像、動画、音声はビューアー側のブラウザが元の URL から読み込むため、メディアのバイト列がリレーを通過することはありません。

Playwright アダプター

既製のアダプターを使ってキャプチャコアを Playwright または CDP ページに組み込み、認可された逆方向のリモート操作を利用できます。

セキュリティ

埋め込み時のセキュリティコントラクト

PhantomStream は、攻撃者の影響を受ける HTML を、構造上スクリプトを実行できない状態で描画します。シリアライズ時に危険な内容を除去するのは転送用クローンだけです。ライブページには一切触れません。

  • サンドボックスは厳密に allow-same-origin のみ。禁止:allow-scripts
  • on* ハンドラー、危険な URL スキーム、srcdocobject / embed を除去
  • 非公開テキストとフォーム値を、転送前にキャプチャ側でマスク
  • フェイルクローズのメディア取得:https のみ、プライベート範囲を拒否、no-referrer

サンドボックスの保証

iframe sandbox = allow-same-origin のみ
CSP メタタグ+解析後のスクラブ処理
ワイヤークローンに対するキャプチャ側のサニタイズ
srcdoc の解析前に文字列レイヤーで検査
ドキュメントレベルの no-referrer ポリシー
クイックスタート

キャプチャ、ミラー、リレー

PhantomStream は各段階に 1 つずつサブパスを公開します。ページコンテキストにキャプチャ、リモートコンテキストにビューアーを接続し、Node 上のリレーで両者のメッセージを配信します。

capture.js
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 | disconnected
import 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 はページを操作しながらビューアーへミラーします。

リファレンス

ドキュメント

詳しい解説はソースと同じ場所にあります。各ドキュメントは単独で読めるように構成されています。