低成本、精確且可定位的即時鏡像
當 AI 代理操作瀏覽器時,負責監督的人需要即時檢視代理正在做什麼。PhantomStream 串流的是DOM 本身,而不是像素。它先將頁面擷取成內嵌樣式的快照,在 WeakMap 中指派穩定識別碼,再以 MutationObserver 監看頁面,只傳送依節點 ID 定位的小型差異(add、rm、attr、text),並依頁面本身的繪製節奏批次送出。
成果是一面頻寬用量取決於頁面實際變化量,而非畫面更新率的鏡像。文字可在任何解析度原生呈現,遠端控制也能用穩定 ID 指向真實元素,不必猜測座標。
PhantomStream 最初是 v0.9.9.1 "Phantom Stream" 里程碑,位於 FSB 內,用來驅動儀表板上的自動瀏覽工作階段即時預覽。此儲存庫將它發展為獨立、即插即用的框架與 SDK(可重新接入 FSB),並作為配套研究論文的實作儲存庫。
像素帶來的問題
即時檢視最直覺的工具是影片。WebRTC、CDP 螢幕廣播或連續截圖,傳送的都是像素。像素很沉重。無論頁面是否改變,每一幀都會消耗頻寬;編碼與解碼會增加延遲,結果也有損且受解析度限制。
更糟的是,像素不透明。監看者無法查詢代理正在接觸哪個元素,不能醒目標示節點、註解畫面,也無法可靠地反向操作頁面。透過影片串流遠端控制,等於在可能已過時的畫面上點擊像素座標;只要稍有偏移,就會點到錯誤的位置。
為何使用 DOM 串流而非影片
串流結構化 DOM 而非畫面影格,會在監督代理所重視的每個面向上,改變鏡像的成本結構與能力。
四階段管線
先擷取頁面,主機再封裝每則訊息以供傳輸,中繼服務將訊息分送給檢視器,最後由檢視器重建並套用。遠端控制則沿同一路徑反向執行。
頁面
快照:複製、內嵌樣式、標記節點 ID,再傳送以 rAF 批次處理的差異
主機
LZ-string 封套、工作階段標記、看門狗
中繼服務
WebSocket 分送、1 MiB 上限、壓力過高時丟棄
檢視器
沙箱化 iframe、依 ID 套用差異、圖層、縮放至合適大小
核心機制
穩定的節點身分
擷取端在 WeakMap<Element, string> 中管理身分,並隨快照與新增操作送出 nodeIds 側載資料,因此延遲抵達的差異仍會套用到正確元素,且不會修改即時頁面。
精選的計算樣式擷取
每個元素只內嵌約 85 項影響視覺精確度的 CSS 屬性,而非全部 300 多項,並省略預設值。這使 YouTube 的序列化時間從 45 秒縮短到可互動的程度。
配合顯示節奏的差異
變更會在 requestAnimationFrame 上批次送出,因此鏡像與頁面繪製保持相同的更新節奏。
工作階段身分
每則訊息都包含 streamSessionId 與 snapshotId。檢視器會拒絕過時訊息,因此上一個頁面的延遲差異永遠不會破壞鏡像。
開箱即用的功能
擷取核心是純 JavaScript,可作為內容指令碼、addInitScript 或書籤小程式注入,不需要執行階段建置步驟。擷取端、檢視器與中繼服務都透過小巧的傳輸介面通訊。
穩定節點 ID
差異與遠端控制操作會依 WeakMap 身分定位節點,絕不依賴座標或脆弱的選取器。
精選樣式內嵌
每個元素約使用 85 項攸關精確度的 CSS 屬性並省略預設值,讓內容繁重的頁面仍可互動。
配合繪製節奏的差異
每次真實變化只產生一個精簡操作,並依頁面本身的更新速率在 requestAnimationFrame 上送出。
有大小預算的快照
快照會在完整元素邊界捨棄畫面外的子樹,確保不超過中繼服務的單則訊息上限。
雙重看門狗
擷取端計時器與主機端警示會各自透過強制送出或建立新快照,恢復卡住的串流。
沙箱化呈現
檢視器會在只允許 allow-same-origin(絕不允許 allow-scripts)的 iframe 沙箱中重建,並搭配 CSP 與剖析後清理。
隱私遮蔽
blockSelector、maskTextSelector 與遮蔽函式,會在敏感內容離開頁面前將其遮蔽。密碼一律遮蔽。
以參照載入媒體
圖片、影片與音訊會在檢視器自己的瀏覽器中從來源 URL 載入,因此媒體位元組不會經過中繼服務。
Playwright 轉接器
透過現成的轉接器,將擷取核心放入 Playwright 或 CDP 頁面,並支援經授權的反向遠端控制。
嵌入式安全合約
PhantomStream 會呈現可能受攻擊者影響的 HTML,但在架構上停用指令碼執行。序列化只會在傳輸副本上移除危險內容。即時頁面絕不會被修改。
- 沙箱僅允許
allow-same-origin,絕不允許allow-scripts - 移除
on*處理常式、危險 URL 設定、srcdoc與object/embed - 私人文字與表單值會在擷取端、傳輸前完成遮蔽
- 媒體擷取採失敗即關閉:僅限 HTTPS、拒絕私人網段、不傳送 referrer
沙箱保證
擷取、鏡像、中繼
PhantomStream 為每個階段提供一個子路徑。在頁面內容脈絡中接上擷取端,在遠端內容脈絡中接上檢視器,再於 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 則在檢視器鏡像頁面的同時操作該頁面。
文件
更深入的說明與原始碼放在一起,每份文件都可獨立閱讀。