연구 · @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는 픽셀 대신 DOMDOM 자체를 전송합니다. 페이지를 스타일이 인라인된 스냅숏으로 한 번 캡처하고, WeakMap에 안정적인 식별자를 부여한 뒤, MutationObserver로 페이지를 감시하며 노드 ID로 지정된 작은 차이(add, rm, attr, text)만 페이지 자체의 페인트 주기에 맞춰 묶어 보냅니다.

그 결과는 프레임 속도가 아니라 페이지가 실제로 얼마나 바뀌는지에 따라 대역폭이 결정되는 미러입니다. 텍스트는 어떤 해상도에서도 네이티브로 렌더링되고, 원격 제어는 좌표를 추측하는 대신 안정적인 ID로 실제 요소를 지정합니다.

PhantomStreamv0.9.9.1 "Phantom Stream" 마일스톤으로 FSB 안에서 시작되었으며, 그곳에서 자동화된 브라우징 세션의 대시보드 실시간 미리 보기를 담당합니다. 이 저장소는 그것을 독립형 플러그 앤 플레이 프레임워크이자 SDK, FSB가 다시 연결해 쓰는 대상, 그리고 함께 진행 중인 연구 논문의 작업 저장소로 만듭니다.

배경

픽셀의 문제

실시간 화면을 위한 명백한 도구는 영상입니다. WebRTC, CDP 스크린캐스트, 연속 스크린샷 모두 픽셀을 전송합니다. 픽셀은 무겁습니다. 페이지가 바뀌었든 아니든 모든 프레임이 대역폭을 소모하고, 인코딩과 디코딩이 지연을 더하며, 결과는 손실이 있고 해상도에 묶입니다.

더 나쁜 점은 픽셀이 불투명하다는 것입니다. 보는 사람은 에이전트가 어떤 요소를 건드리는지 물을 수 없고, 노드를 강조할 수도, 화면에 주석을 달 수도, 페이지를 안정적으로 되조작할 수도 없습니다. 영상 스트림을 통한 원격 제어는 이미 오래되었을 수 있는 프레임을 기준으로 픽셀 좌표를 클릭한다는 뜻이므로, 어긋난 클릭은 엉뚱한 곳에 닿습니다.

비교

왜 영상이 아니라 DOM 스트리밍인가

프레임이 아니라 구조화된 DOM을 전송하면, 에이전트를 감독하는 데 중요한 모든 축에서 미러의 경제성과 성능이 달라집니다.

영상 / 스크린샷
PhantomStream
대역폭
페이지 내용과 무관한 연속 프레임
스냅숏 한 번, 이후에는 페이지가 바뀔 때만 작은 차이
지연 시간
프레임마다 인코딩, 전송, 디코딩
텍스트 변경은 작은 JSON 연산 하나
충실도
손실이 있고 해상도에 묶임
정확한 DOM, 네이티브 텍스트 렌더링, 해상도 독립
원격 제어
오래되었을 수 있는 프레임 기준의 픽셀 좌표
안정적인 노드 ID로 지정되는 실제 요소
검사 가능성
불투명한 픽셀
미러 자체가 DOM입니다. 조회하고, 강조하고, 주석을 달 수 있습니다
아키텍처

네 단계 파이프라인

페이지를 캡처하고, 호스트가 각 메시지를 전송용으로 감싸고, 릴레이가 뷰어들에게 메시지를 분배하고, 뷰어가 이를 재구성해 적용합니다. 원격 제어는 같은 경로를 역방향으로 지납니다.

페이지

스냅숏: 복제, 스타일 인라인, 노드 ID 부여, 이후 rAF 단위로 묶은 차이

호스트

LZ-string 봉투, 세션 각인, 워치독

릴레이

WebSocket 분배, 1 MiB 상한, 백프레셔 시 폐기

뷰어

샌드박스 iframe, ID 기준 차이 적용, 오버레이, 화면 맞춤 축소

핵심 메커니즘

안정적인 노드 신원

캡처가 WeakMap<Element, string>에서 신원을 소유하고 스냅숏과 추가 연산에 nodeIds 사이드카를 함께 보내므로, 늦게 도착한 차이도 실시간 페이지를 변경하지 않고 항상 올바른 요소에 적용됩니다.

선별된 계산 스타일 캡처

300개가 넘는 전체가 아니라 시각적 충실도에 중요한 약 85개의 CSS 속성만 요소별로 인라인하며, 기본값은 생략합니다. 이 덕분에 YouTube 직렬화가 45초에서 즉시 조작 가능한 수준으로 줄었습니다.

표시 주기에 맞춘 차이 계산

변경 사항은 requestAnimationFrame에서 묶여 전송되므로, 미러는 페이지가 그리는 것과 같은 주기로 갱신됩니다.

세션 신원

모든 메시지는 streamSessionIdsnapshotId를 담고 있습니다. 뷰어가 오래된 메시지를 거부하므로, 이전 페이지에서 늦게 도착한 차이가 미러를 손상시킬 수 없습니다.

기능

기본으로 제공되는 것

캡처 코어는 순수 JavaScript이며 콘텐츠 스크립트, addInitScript, 또는 북마클릿으로 주입할 수 있고 런타임 빌드 단계가 필요 없습니다. 캡처, 뷰어, 릴레이는 모두 작은 전송 이음매를 통해 통신합니다.

안정적인 노드 ID

차이와 원격 제어 동작은 좌표나 취약한 선택자가 아니라 WeakMap 신원으로 노드를 지정합니다.

선별된 스타일 인라인

요소당 충실도에 중요한 약 85개의 CSS 속성만 기본값을 생략해 인라인하므로 무거운 페이지도 계속 조작할 수 있습니다.

페인트 주기 차이

실제 변경 하나당 간결한 연산 하나를 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 전용, 사설 대역 거부, 리퍼러 없음

샌드박스 보장

iframe sandbox = allow-same-origin
CSP 메타 태그 + 파싱 후 정화
전송용 복제본에 대한 캡처 측 정화
파싱 전 문자열 계층 게이트: srcdoc parse
문서 수준 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는 뷰어가 미러링하는 동안 페이지를 조작합니다.

참고

문서

더 깊은 설명은 소스와 함께 있습니다. 각 문서는 독립적으로 읽을 수 있습니다.