Research · @full-self-browsing

PhantomStream.

DOM-native live browser mirroring. A real tab streamed as structured DOM, not pixels.

PhantomStream sends one style-inlined snapshot, then tiny MutationObserver diffs addressed by stable node IDs. The viewer gets a live, semantically addressable, remotely controllable copy of the page at a fraction of the bandwidth of screen streaming.

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

A live mirror that is cheap, exact, and addressable

When an AI agent drives a browser, the human supervising it needs a live view of what the agent is doing. PhantomStream streams the DOM itself instead of pixels. It captures the page once as a style-inlined snapshot, assigns stable identifiers in a WeakMap, then watches the page with a MutationObserver and sends only small diffs (add, rm, attr, text) keyed by node ID and batched to the page's own paint cadence.

The payoff is a mirror whose bandwidth tracks how much the page actually changes rather than the frame rate. Text renders natively at any resolution, and remote control targets real elements by their stable IDs instead of guessing at coordinates.

PhantomStream began as milestone v0.9.9.1 "Phantom Stream" inside FSB, where it powers the dashboard's live preview of automated browsing sessions. This repository turns it into a standalone plug-and-play framework, an SDKFSB plugs back in, and the working repository for an accompanying research paper.

Motivation

The problem with pixels

The obvious tools for a live view are video. WebRTC, a CDP screencast, or a sequence of screenshots all send pixels. Pixels are heavy. Every frame costs bandwidth whether the page changed or not, encoding and decoding add latency, and the result is lossy and resolution bound.

Worse, pixels are opaque. The watcher cannot ask which element the agent is touching, cannot highlight a node, cannot annotate the view, and cannot reliably drive the page back. Remote control over a video stream means clicking pixel coordinates against a frame that may already be stale, so a misaligned click lands on the wrong thing.

Comparison

Why DOM streaming instead of video

Streaming structured DOM rather than frames changes the economics and the capabilities of the mirror across every axis that matters for supervising an agent.

Video / screenshots
PhantomStream
Bandwidth
Continuous frames, independent of page content
One snapshot, then tiny diffs only when the page changes
Latency
Encode, transmit, then decode per frame
A text mutation is one small JSON op
Fidelity
Lossy and resolution bound
Exact DOM, native text rendering, resolution independent
Remote control
Pixel coordinates against a possibly stale frame
Real elements addressed by stable node IDs
Inspectability
Opaque pixels
The mirror is a DOM: queryable, highlightable, annotatable
Architecture

A four-stage pipeline

The page is captured, the host wraps each message for transport, a relay fans messages out to viewers, and the viewer rebuilds and applies them. Remote control runs the same path in reverse.

Page

Snapshot: clone, inline styles, stamp node IDs, then rAF-batched diffs

Host

LZ-string envelope, session stamping, watchdog

Relay

WebSocket fan-out, 1 MiB cap, backpressure drop

Viewer

Sandboxed iframe, diff apply by ID, overlays, scale-to-fit

Core mechanisms

Stable node identity

Capture owns identity in a WeakMap<Element, string> and emits nodeIds sidecars with snapshots and add ops, so a late diff always lands on the right element without mutating the live page.

Curated computed-style capture

Roughly 85 visual-fidelity CSS properties are inlined per element rather than all 300+, with default-value elision. This took a YouTube serialize from 45 seconds down to interactive.

Display-matched diffing

Mutations batch and flush on requestAnimationFrame, so the mirror updates at the same cadence the page paints.

Session identity

Every message carries a streamSessionId and a snapshotId. The viewer rejects stale messages, so late diffs from a previous page can never corrupt the mirror.

Capabilities

What you get out of the box

The capture core is plain JavaScript that injects as a content script, an addInitScript, or a bookmarklet, with no runtime build step. Capture, viewer, and relay all talk through a small transport seam.

Stable node IDs

Diffs and remote-control actions address nodes by WeakMap identity, never by coordinate or fragile selector.

Curated style inlining

About 85 fidelity-critical CSS properties per element with default elision, keeping heavy pages interactive.

Paint-cadence diffs

One compact op per real change, flushed on requestAnimationFrame at the page's own update rate.

Budgeted snapshots

Snapshots stay under the relay's per-message cap by dropping offscreen subtrees on whole-element boundaries.

Dual watchdogs

A capture-side timer and a host-side alarm independently recover a wedged stream by forcing a flush or fresh snapshot.

Sandboxed rendering

The viewer rebuilds inside an iframe sandboxed to exactly allow-same-origin (never allow-scripts) with CSP and a post-parse scrub.

Privacy masking

blockSelector, maskTextSelector, and mask functions redact sensitive content before it leaves the page. Passwords always masked.

Media by reference

Images, video, and audio load from their source URL in the viewer's own browser, so media bytes never cross the relay.

Playwright adapter

Drop the capture core into a Playwright or CDP page through a ready-made adapter, with authorized reverse remote control.

Security

The embed security contract

PhantomStream renders attacker-influenced HTML with script execution disabled by construction. Serialization strips dangerous content on the wire clone only. The live page is never touched.

  • Sandbox is exactly allow-same-origin, never allow-scripts
  • on* handlers, dangerous URL schemes, srcdoc, and object/embed stripped
  • Private text and form values masked capture-side before transport
  • Fail-closed media fetch: https-only, private-range deny, no-referrer

Sandbox guarantees

iframe sandbox = allow-same-origin only
CSP meta tag + post-parse scrub
Capture-side sanitization on wire clone
String-layer gate before srcdoc parse
Document-level no-referrer policy
Quickstart

Capture, mirror, relay

PhantomStream exposes one subpath per stage. Wire a capture in the page context, a viewer in the remote context, and a relay on Node to fan messages between them.

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' // exclude your own UI
});

capture.start(); // snapshot once, then stream diffs
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('viewer is', 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 per-message cap, backpressure drop
const server = http.createServer();
createWebSocketRelayBackend({ server, relay, path: '/ws' });
server.listen(8787);

// clients join with ?room=<id>&role=source|viewer

Or run it straight from the repository: npm run demo mirrors a source tab into a viewer tab, and npm run demo:playwright drives a page while a viewer mirrors it.

Reference

Documentation

The deeper treatment lives alongside the source. Each document is self-contained.