研究 · @full-self-browsing

PhantomStream.

DOM 原生的即時瀏覽器鏡像。把真實分頁以結構化 DOM 串流,而不是像素。

PhantomStream 會先傳送一份內嵌樣式的快照,之後只傳送由穩定節點 ID 定位的微小 MutationObserver 差異。檢視器只需螢幕串流的一小部分頻寬,就能取得即時、可按語意定位且可遠端控制的頁面副本。

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

低成本、精確且可定位的即時鏡像

AI 代理操作瀏覽器時,負責監督的人需要即時檢視代理正在做什麼。PhantomStream 串流的是DOM 本身,而不是像素。它先將頁面擷取成內嵌樣式的快照,在 WeakMap 中指派穩定識別碼,再以 MutationObserver 監看頁面,只傳送依節點 ID 定位的小型差異(addrmattrtext),並依頁面本身的繪製節奏批次送出。

成果是一面頻寬用量取決於頁面實際變化量,而非畫面更新率的鏡像。文字可在任何解析度原生呈現,遠端控制也能用穩定 ID 指向真實元素,不必猜測座標。

PhantomStream 最初是 v0.9.9.1 "Phantom Stream" 里程碑,位於 FSB 內,用來驅動儀表板上的自動瀏覽工作階段即時預覽。此儲存庫將它發展為獨立、即插即用的框架與 SDK(可重新接入 FSB),並作為配套研究論文的實作儲存庫。

動機

像素帶來的問題

即時檢視最直覺的工具是影片。WebRTCCDP 螢幕廣播或連續截圖,傳送的都是像素。像素很沉重。無論頁面是否改變,每一幀都會消耗頻寬;編碼與解碼會增加延遲,結果也有損且受解析度限制。

更糟的是,像素不透明。監看者無法查詢代理正在接觸哪個元素,不能醒目標示節點、註解畫面,也無法可靠地反向操作頁面。透過影片串流遠端控制,等於在可能已過時的畫面上點擊像素座標;只要稍有偏移,就會點到錯誤的位置。

比較

為何使用 DOM 串流而非影片

串流結構化 DOM 而非畫面影格,會在監督代理所重視的每個面向上,改變鏡像的成本結構與能力。

影片/截圖
PhantomStream
頻寬
持續傳送影格,與頁面內容無關
先傳一份快照,之後只有頁面變化時才傳送微小差異
延遲
每一幀都要編碼、傳輸,再解碼
文字變更只是一個很小的 JSON 操作
精確度
有損且受解析度限制
精確的 DOM、原生文字呈現,不受解析度限制
遠端控制
在可能已過時的影格上使用像素座標
以穩定節點 ID 定位真實元素
可檢查性
不透明的像素
鏡像就是 DOM:可查詢、可醒目標示、可註解
架構

四階段管線

先擷取頁面,主機再封裝每則訊息以供傳輸,中繼服務將訊息分送給檢視器,最後由檢視器重建並套用。遠端控制則沿同一路徑反向執行。

頁面

快照:複製、內嵌樣式、標記節點 ID,再傳送以 rAF 批次處理的差異

主機

LZ-string 封套、工作階段標記、看門狗

中繼服務

WebSocket 分送、1 MiB 上限、壓力過高時丟棄

檢視器

沙箱化 iframe、依 ID 套用差異、圖層、縮放至合適大小

核心機制

穩定的節點身分

擷取端在 WeakMap<Element, string> 中管理身分,並隨快照與新增操作送出 nodeIds 側載資料,因此延遲抵達的差異仍會套用到正確元素,且不會修改即時頁面。

精選的計算樣式擷取

每個元素只內嵌約 85 項影響視覺精確度的 CSS 屬性,而非全部 300 多項,並省略預設值。這使 YouTube 的序列化時間從 45 秒縮短到可互動的程度。

配合顯示節奏的差異

變更會在 requestAnimationFrame 上批次送出,因此鏡像與頁面繪製保持相同的更新節奏。

工作階段身分

每則訊息都包含 streamSessionIdsnapshotId。檢視器會拒絕過時訊息,因此上一個頁面的延遲差異永遠不會破壞鏡像。

能力

開箱即用的功能

擷取核心是純 JavaScript,可作為內容指令碼、addInitScript 或書籤小程式注入,不需要執行階段建置步驟。擷取端、檢視器與中繼服務都透過小巧的傳輸介面通訊。

穩定節點 ID

差異與遠端控制操作會依 WeakMap 身分定位節點,絕不依賴座標或脆弱的選取器。

精選樣式內嵌

每個元素約使用 85 項攸關精確度的 CSS 屬性並省略預設值,讓內容繁重的頁面仍可互動。

配合繪製節奏的差異

每次真實變化只產生一個精簡操作,並依頁面本身的更新速率在 requestAnimationFrame 上送出。

有大小預算的快照

快照會在完整元素邊界捨棄畫面外的子樹,確保不超過中繼服務的單則訊息上限。

雙重看門狗

擷取端計時器與主機端警示會各自透過強制送出或建立新快照,恢復卡住的串流。

沙箱化呈現

檢視器會在只允許 allow-same-origin(絕不允許 allow-scripts)的 iframe 沙箱中重建,並搭配 CSP 與剖析後清理。

隱私遮蔽

blockSelectormaskTextSelector 與遮蔽函式,會在敏感內容離開頁面前將其遮蔽。密碼一律遮蔽。

以參照載入媒體

圖片、影片與音訊會在檢視器自己的瀏覽器中從來源 URL 載入,因此媒體位元組不會經過中繼服務。

Playwright 轉接器

透過現成的轉接器,將擷取核心放入 PlaywrightCDP 頁面,並支援經授權的反向遠端控制。

安全性

嵌入式安全合約

PhantomStream 會呈現可能受攻擊者影響的 HTML,但在架構上停用指令碼執行。序列化只會在傳輸副本上移除危險內容。即時頁面絕不會被修改。

  • 沙箱僅允許 allow-same-origin,絕不允許 allow-scripts
  • 移除 on* 處理常式、危險 URL 設定、srcdocobjectembed
  • 私人文字與表單值會在擷取端、傳輸前完成遮蔽
  • 媒體擷取採失敗即關閉:僅限 HTTPS、拒絕私人網段、不傳送 referrer

沙箱保證

iframe sandbox = allow-same-origin(僅限此設定)
CSP 中繼資料標籤與剖析後清理
擷取端對傳輸複本進行清理
srcdoc 剖析前通過字串層閘門
文件層級的 no-referrer 原則
快速開始

擷取、鏡像、中繼

PhantomStream 為每個階段提供一個子路徑。在頁面內容脈絡中接上擷取端,在遠端內容脈絡中接上檢視器,再於 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 則在檢視器鏡像頁面的同時操作該頁面。

參考資料

文件

更深入的說明與原始碼放在一起,每份文件都可獨立閱讀。