diff --git a/website/src/routes/_index.tsx b/website/src/routes/_index.tsx index adddad1..1d13050 100644 --- a/website/src/routes/_index.tsx +++ b/website/src/routes/_index.tsx @@ -1,9 +1,487 @@ -import { redirect } from 'react-router'; +/* + * Playwriter editorial page — content only. + * Components imported from website/src/components/markdown.tsx. + * Styles from liveline.css and liveline-prism.css. + */ -export const loader = () => { - throw redirect('https://github.com/remorses/playwriter'); +import type { MetaFunction } from "react-router"; +import dedent from "string-dedent"; +import { + EditorialPage, + P, + A, + Code, + Caption, + CodeBlock, + Section, + ComparisonTable, + List, + OL, + Li, +} from "website/src/components/markdown"; + +export const meta: MetaFunction = () => { + const title = "Playwriter - Control your Chrome with Playwright API"; + const description = + "Chrome extension + CLI for browser automation. Full Playwright API on your existing browser. No new windows, no flags, no context bloat."; + const image = "https://playwriter.dev/og-image.png"; + return [ + { title }, + { name: "description", content: description }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:image", content: image }, + { property: "og:image:width", content: "1200" }, + { property: "og:image:height", content: "630" }, + { property: "og:type", content: "website" }, + { property: "og:url", content: "https://playwriter.dev/liveline" }, + { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: description }, + { name: "twitter:image", content: image }, + ]; }; -export default function Index() { - return null; +const tocItems = [ + { label: "Getting started", href: "#getting-started" }, + { label: "How it works", href: "#how-it-works" }, + { label: "Snapshots", href: "#snapshots" }, + { label: "Visual labels", href: "#visual-labels" }, + { label: "Sessions", href: "#sessions" }, + { label: "Debugger & editor", href: "#debugger-and-editor" }, + { label: "Network interception", href: "#network-interception" }, + { label: "Screen recording", href: "#screen-recording" }, + { label: "Comparison", href: "#comparison" }, + { label: "Remote access", href: "#remote-access" }, + { label: "Security", href: "#security" }, +]; + +export default function LivelinePage() { + return ( + + +

+ Playwriter lets you control your Chrome browser with the full + Playwright API. A Chrome extension, a local relay, and a CLI. No new + browser windows, no Chrome flags, no context bloat.{" "} + Star on GitHub. +

+ +
+ Playwriter controlling Chrome with accessibility labels overlay +
+ + Your existing Chrome session. Extensions, logins, cookies — all there. + + +

+ Every browser automation MCP I tried either spawns a new Chrome + instance or forces you into a limited set of predefined tools. Playwriter + does neither. It connects to the browser you already have open, + exposes the full Playwright API through a single{" "} + execute tool, and gets out of the way. + One tool. Any Playwright code. No wrappers. +

+ +
+ +

+ Three steps. Extension, icon click, then you're automating. +

+ +
    +
  1. + Install the{" "} + Chrome extension +
  2. +
  3. Click the extension icon on a tab — it turns green
  4. +
  5. Install CLI and run your first command:
  6. +
+ + {dedent` + npm i -g playwriter + playwriter -s 1 -e "await page.goto('https://example.com')" + `} + +

+ The extension connects your browser to a local WebSocket relay on{" "} + localhost:19988. The CLI sends Playwright + code through the relay. No remote servers, no accounts, nothing + leaves your machine. +

+ + {dedent` + playwriter session new # new sandbox, outputs id (e.g. 1) + playwriter -s 1 -e "await page.goto('https://example.com')" + playwriter -s 1 -e "console.log(await snapshot({ page }))" + playwriter -s 1 -e "await page.locator('aria-ref=e5').click()" + `} + + + Extension icon green = connected. Gray = not attached to this tab. + + +
+ +
+ +

+ The extension uses chrome.debugger to + attach to tabs where you clicked the icon. It opens a WebSocket + connection to a local relay server. The CLI (or MCP, or your own + Playwright script) connects to the same relay. CDP commands flow + through; the extension forwards them to Chrome and sends responses + back. +

+ + {dedent` + ┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────┐ + │ BROWSER │ │ LOCALHOST │ │ CLIENT │ + │ │ │ │ │ │ + │ ┌───────────────┐ │ │ WebSocket Server │ │ ┌───────────┐ │ + │ │ Extension │<───────┬───> :19988 │ │ │ CLI / MCP │ │ + │ └───────┬───────┘ │ WS │ │ │ └───────────┘ │ + │ │ │ │ /extension │ │ │ │ + │ chrome.debugger │ │ │ │ │ v │ + │ v │ │ v │ │ ┌────────────┐ │ + │ ┌───────────────┐ │ │ /cdp/:id <───────────────>│ │ execute │ │ + │ │ Tab 1 (green) │ │ └──────────────────────┘ WS │ └────────────┘ │ + │ │ Tab 2 (green) │ │ │ │ │ + │ │ Tab 3 (gray) │ │ Tab 3 not controlled │ Playwright API │ + └─────────────────────┘ (extension not clicked) └─────────────────┘ + `} + +

+ No Chrome restart required. No --remote-debugging-port{" "} + flags. The extension handles the CDP attachment transparently, and + the relay multiplexes sessions so multiple agents or CLI instances + can work with the same browser simultaneously. +

+ +
+ +
+ +

+ The core feedback loop is observe → act → observe. + Accessibility snapshots are the primary way to read page state. They return + the full interactive element tree as text, with Playwright locators attached + to every element. +

+ + {dedent` + playwriter -s 1 -e "await snapshot({ page })" + + # Output: + # - banner: + # - link "Home" [id="nav-home"] + # - navigation: + # - link "Docs" [data-testid="docs-link"] + # - link "Blog" role=link[name="Blog"] + `} + +

+ Each line ends with a locator you can pass directly to{" "} + page.locator(). Subsequent calls return a + diff, so you only see what changed. Use{" "} + search to filter large pages. +

+ + {dedent` + # Search for specific elements + playwriter -s 1 -e "await snapshot({ page, search: /button|submit/i })" + + # Always print URL first, then snapshot — pages can redirect + playwriter -s 1 -e "console.log('URL:', page.url()); await snapshot({ page }).then(console.log)" + `} + +

+ Snapshots are text. They cost a fraction of what screenshots cost in + tokens. Use them as your primary debugging tool. Only reach for + screenshots when spatial layout matters — grids, dashboards, maps. +

+ + + Accessibility tree as text. 5–20KB vs 100KB+ for screenshots. + + +
+ +
+ +

+ For pages where spatial layout matters,{" "} + screenshotWithAccessibilityLabels overlays{" "} + Vimium-style labels on every interactive element. Take a screenshot, + read the labels, click by reference. +

+ + {dedent` + playwriter -s 1 -e "await screenshotWithAccessibilityLabels({ page })" + # Returns screenshot + accessibility snapshot with aria-ref selectors + + playwriter -s 1 -e "await page.locator('aria-ref=e5').click()" + `} + +

+ Labels are color-coded by element type: yellow for links, orange for + buttons, coral for inputs, pink for checkboxes, peach for sliders, + salmon for menus, amber for tabs. The ref system is shared with{" "} + snapshot(), so you can switch between text + and visual modes freely. +

+ + + Vimium-style labels. Screenshot + snapshot in one call. + + +
+ +
+ +

+ Each session runs in an isolated sandbox with its own{" "} + state object. Variables, pages, listeners + persist between calls within a session. Different sessions get + different state. Browser tabs are shared. +

+ + {dedent` + playwriter session new # => 1 + playwriter session new # => 2 + playwriter session list # shows sessions + state keys + + # Session 1 stores data + playwriter -s 1 -e "state.users = await page.$$eval('.user', els => els.map(e => e.textContent))" + + # Session 2 can't see it + playwriter -s 2 -e "console.log(state.users)" # undefined + `} + +

+ Create your own page to avoid interference from other agents. Reuse + an existing about:blank tab or create a + fresh one, and store it in state. +

+ + {dedent` + playwriter -s 1 -e "state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage(); await state.myPage.goto('https://example.com')" + + # All subsequent calls use state.myPage + playwriter -s 1 -e "console.log(await state.myPage.title())" + `} + +
+ +
+ +

+ Full Chrome DevTools Protocol access. Set breakpoints, step through + code, inspect variables at runtime. Live-edit page scripts and CSS + without reloading. +

+ + {dedent` + # Set breakpoints and debug + playwriter -s 1 -e "state.cdp = await getCDPSession({ page }); state.dbg = createDebugger({ cdp: state.cdp }); await state.dbg.enable()" + playwriter -s 1 -e "state.scripts = await state.dbg.listScripts({ search: 'app' }); console.log(state.scripts.map(s => s.url))" + playwriter -s 1 -e "await state.dbg.setBreakpoint({ file: state.scripts[0].url, line: 42 })" + + # Live edit page code + playwriter -s 1 -e "state.editor = createEditor({ cdp: state.cdp }); await state.editor.enable()" + playwriter -s 1 -e "await state.editor.edit({ url: 'https://example.com/app.js', oldString: 'const DEBUG = false', newString: 'const DEBUG = true' })" + `} + +

+ Edits are in-memory and persist until the page reloads. Useful for + toggling debug flags, patching broken code, or testing quick fixes + without touching source files. The editor also supports{" "} + grep across all loaded scripts. +

+ + + Breakpoints, stepping, variable inspection — from the CLI. + + +
+ +
+ +

+ Intercept requests and responses to reverse-engineer APIs, scrape + data, or debug network issues. Store captured data in{" "} + state and analyze across calls. +

+ + {dedent` + # Start intercepting + playwriter -s 1 -e "state.responses = []; page.on('response', async res => { if (res.url().includes('/api/')) { try { state.responses.push({ url: res.url(), status: res.status(), body: await res.json() }); } catch {} } })" + + # Trigger actions, then analyze + playwriter -s 1 -e "await page.click('button.load-more')" + playwriter -s 1 -e "console.log('Captured', state.responses.length, 'API calls'); state.responses.forEach(r => console.log(r.status, r.url.slice(0, 80)))" + + # Replay an API call directly + playwriter -s 1 -e "const data = await page.evaluate(async (url) => { const res = await fetch(url); return res.json(); }, state.responses[0].url); console.log(data)" + `} + +

+ This is faster than scrolling through DOM. Capture the real API + calls, inspect their schemas, and replay them with different + parameters. Works for pagination, authenticated endpoints, and + anything behind JavaScript rendering. +

+ +
+ +
+ +

+ Record the active tab as video using{" "} + chrome.tabCapture. The recording runs in + the extension context, so it survives page navigation. Video is saved + as MP4. +

+ + {dedent` + # Start recording + playwriter -s 1 -e "await startRecording({ page, outputPath: './recording.mp4', frameRate: 30 })" + + # Navigate, interact — recording continues + playwriter -s 1 -e "await page.click('a'); await page.waitForLoadState('domcontentloaded')" + playwriter -s 1 -e "await page.goBack()" + + # Stop and save + playwriter -s 1 -e "const { path, duration, size } = await stopRecording({ page }); console.log(path, duration + 'ms', size + ' bytes')" + `} + +

+ Unlike getDisplayMedia, this approach + persists across navigations because the extension holds the{" "} + MediaRecorder, not the page. You can also + check recording status with isRecording or + cancel without saving with cancelRecording. +

+ + + Native tab capture. 30–60fps. Survives navigation. + + +
+ +
+ +

+ How Playwriter compares to other browser automation approaches. +

+ + + + + + + +
+ +
+ +

+ Control Chrome on any machine from anywhere over the internet. + The relay runs on the host alongside Chrome. + A{" "} + traforo{" "} + tunnel exposes it through Cloudflare, giving you a secure public URL. + No VPN, no firewall rules, no port forwarding. +

+ + {dedent` + # On the host machine — start relay with tunnel + npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token + + # From anywhere — set env vars and use normally + export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev + export PLAYWRITER_TOKEN= + playwriter -s 1 -e "await page.goto('https://example.com')" + `} + +

+ Also works on a LAN without tunnels — just set{" "} + PLAYWRITER_HOST=192.168.1.10. Works for MCP + too — set PLAYWRITER_HOST and{" "} + PLAYWRITER_TOKEN in your MCP client env config. + Use cases: headless Mac mini, remote user support, + multi-machine automation, dev from a VM or devcontainer. +

+ +
+ +
+ +

+ Playwriter is local by default. The relay runs on{" "} + localhost:19988 and only accepts connections + from the extension. There's no remote server, no account, no + telemetry. +

+ + +
  • + Local only — WebSocket server binds to + localhost. Nothing leaves your machine. +
  • +
  • + Origin validation — only the Playwriter + extension origin is accepted. Browsers cannot spoof the Origin + header, so malicious websites cannot connect. +
  • +
  • + Explicit consent — only tabs where you + clicked the extension icon are controlled. No background access. +
  • +
  • + Visible automation — Chrome shows an + automation banner on controlled tabs. +
  • +
    + +
    + +
    + ); } diff --git a/website/src/routes/github.tsx b/website/src/routes/github.tsx new file mode 100644 index 0000000..adddad1 --- /dev/null +++ b/website/src/routes/github.tsx @@ -0,0 +1,9 @@ +import { redirect } from 'react-router'; + +export const loader = () => { + throw redirect('https://github.com/remorses/playwriter'); +}; + +export default function Index() { + return null; +} diff --git a/website/src/routes/liveline.tsx b/website/src/routes/liveline.tsx deleted file mode 100644 index 1d13050..0000000 --- a/website/src/routes/liveline.tsx +++ /dev/null @@ -1,487 +0,0 @@ -/* - * Playwriter editorial page — content only. - * Components imported from website/src/components/markdown.tsx. - * Styles from liveline.css and liveline-prism.css. - */ - -import type { MetaFunction } from "react-router"; -import dedent from "string-dedent"; -import { - EditorialPage, - P, - A, - Code, - Caption, - CodeBlock, - Section, - ComparisonTable, - List, - OL, - Li, -} from "website/src/components/markdown"; - -export const meta: MetaFunction = () => { - const title = "Playwriter - Control your Chrome with Playwright API"; - const description = - "Chrome extension + CLI for browser automation. Full Playwright API on your existing browser. No new windows, no flags, no context bloat."; - const image = "https://playwriter.dev/og-image.png"; - return [ - { title }, - { name: "description", content: description }, - { property: "og:title", content: title }, - { property: "og:description", content: description }, - { property: "og:image", content: image }, - { property: "og:image:width", content: "1200" }, - { property: "og:image:height", content: "630" }, - { property: "og:type", content: "website" }, - { property: "og:url", content: "https://playwriter.dev/liveline" }, - { name: "twitter:card", content: "summary_large_image" }, - { name: "twitter:title", content: title }, - { name: "twitter:description", content: description }, - { name: "twitter:image", content: image }, - ]; -}; - -const tocItems = [ - { label: "Getting started", href: "#getting-started" }, - { label: "How it works", href: "#how-it-works" }, - { label: "Snapshots", href: "#snapshots" }, - { label: "Visual labels", href: "#visual-labels" }, - { label: "Sessions", href: "#sessions" }, - { label: "Debugger & editor", href: "#debugger-and-editor" }, - { label: "Network interception", href: "#network-interception" }, - { label: "Screen recording", href: "#screen-recording" }, - { label: "Comparison", href: "#comparison" }, - { label: "Remote access", href: "#remote-access" }, - { label: "Security", href: "#security" }, -]; - -export default function LivelinePage() { - return ( - - -

    - Playwriter lets you control your Chrome browser with the full - Playwright API. A Chrome extension, a local relay, and a CLI. No new - browser windows, no Chrome flags, no context bloat.{" "} - Star on GitHub. -

    - -
    - Playwriter controlling Chrome with accessibility labels overlay -
    - - Your existing Chrome session. Extensions, logins, cookies — all there. - - -

    - Every browser automation MCP I tried either spawns a new Chrome - instance or forces you into a limited set of predefined tools. Playwriter - does neither. It connects to the browser you already have open, - exposes the full Playwright API through a single{" "} - execute tool, and gets out of the way. - One tool. Any Playwright code. No wrappers. -

    - -
    - -

    - Three steps. Extension, icon click, then you're automating. -

    - -
      -
    1. - Install the{" "} - Chrome extension -
    2. -
    3. Click the extension icon on a tab — it turns green
    4. -
    5. Install CLI and run your first command:
    6. -
    - - {dedent` - npm i -g playwriter - playwriter -s 1 -e "await page.goto('https://example.com')" - `} - -

    - The extension connects your browser to a local WebSocket relay on{" "} - localhost:19988. The CLI sends Playwright - code through the relay. No remote servers, no accounts, nothing - leaves your machine. -

    - - {dedent` - playwriter session new # new sandbox, outputs id (e.g. 1) - playwriter -s 1 -e "await page.goto('https://example.com')" - playwriter -s 1 -e "console.log(await snapshot({ page }))" - playwriter -s 1 -e "await page.locator('aria-ref=e5').click()" - `} - - - Extension icon green = connected. Gray = not attached to this tab. - - -
    - -
    - -

    - The extension uses chrome.debugger to - attach to tabs where you clicked the icon. It opens a WebSocket - connection to a local relay server. The CLI (or MCP, or your own - Playwright script) connects to the same relay. CDP commands flow - through; the extension forwards them to Chrome and sends responses - back. -

    - - {dedent` - ┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────┐ - │ BROWSER │ │ LOCALHOST │ │ CLIENT │ - │ │ │ │ │ │ - │ ┌───────────────┐ │ │ WebSocket Server │ │ ┌───────────┐ │ - │ │ Extension │<───────┬───> :19988 │ │ │ CLI / MCP │ │ - │ └───────┬───────┘ │ WS │ │ │ └───────────┘ │ - │ │ │ │ /extension │ │ │ │ - │ chrome.debugger │ │ │ │ │ v │ - │ v │ │ v │ │ ┌────────────┐ │ - │ ┌───────────────┐ │ │ /cdp/:id <───────────────>│ │ execute │ │ - │ │ Tab 1 (green) │ │ └──────────────────────┘ WS │ └────────────┘ │ - │ │ Tab 2 (green) │ │ │ │ │ - │ │ Tab 3 (gray) │ │ Tab 3 not controlled │ Playwright API │ - └─────────────────────┘ (extension not clicked) └─────────────────┘ - `} - -

    - No Chrome restart required. No --remote-debugging-port{" "} - flags. The extension handles the CDP attachment transparently, and - the relay multiplexes sessions so multiple agents or CLI instances - can work with the same browser simultaneously. -

    - -
    - -
    - -

    - The core feedback loop is observe → act → observe. - Accessibility snapshots are the primary way to read page state. They return - the full interactive element tree as text, with Playwright locators attached - to every element. -

    - - {dedent` - playwriter -s 1 -e "await snapshot({ page })" - - # Output: - # - banner: - # - link "Home" [id="nav-home"] - # - navigation: - # - link "Docs" [data-testid="docs-link"] - # - link "Blog" role=link[name="Blog"] - `} - -

    - Each line ends with a locator you can pass directly to{" "} - page.locator(). Subsequent calls return a - diff, so you only see what changed. Use{" "} - search to filter large pages. -

    - - {dedent` - # Search for specific elements - playwriter -s 1 -e "await snapshot({ page, search: /button|submit/i })" - - # Always print URL first, then snapshot — pages can redirect - playwriter -s 1 -e "console.log('URL:', page.url()); await snapshot({ page }).then(console.log)" - `} - -

    - Snapshots are text. They cost a fraction of what screenshots cost in - tokens. Use them as your primary debugging tool. Only reach for - screenshots when spatial layout matters — grids, dashboards, maps. -

    - - - Accessibility tree as text. 5–20KB vs 100KB+ for screenshots. - - -
    - -
    - -

    - For pages where spatial layout matters,{" "} - screenshotWithAccessibilityLabels overlays{" "} - Vimium-style labels on every interactive element. Take a screenshot, - read the labels, click by reference. -

    - - {dedent` - playwriter -s 1 -e "await screenshotWithAccessibilityLabels({ page })" - # Returns screenshot + accessibility snapshot with aria-ref selectors - - playwriter -s 1 -e "await page.locator('aria-ref=e5').click()" - `} - -

    - Labels are color-coded by element type: yellow for links, orange for - buttons, coral for inputs, pink for checkboxes, peach for sliders, - salmon for menus, amber for tabs. The ref system is shared with{" "} - snapshot(), so you can switch between text - and visual modes freely. -

    - - - Vimium-style labels. Screenshot + snapshot in one call. - - -
    - -
    - -

    - Each session runs in an isolated sandbox with its own{" "} - state object. Variables, pages, listeners - persist between calls within a session. Different sessions get - different state. Browser tabs are shared. -

    - - {dedent` - playwriter session new # => 1 - playwriter session new # => 2 - playwriter session list # shows sessions + state keys - - # Session 1 stores data - playwriter -s 1 -e "state.users = await page.$$eval('.user', els => els.map(e => e.textContent))" - - # Session 2 can't see it - playwriter -s 2 -e "console.log(state.users)" # undefined - `} - -

    - Create your own page to avoid interference from other agents. Reuse - an existing about:blank tab or create a - fresh one, and store it in state. -

    - - {dedent` - playwriter -s 1 -e "state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage(); await state.myPage.goto('https://example.com')" - - # All subsequent calls use state.myPage - playwriter -s 1 -e "console.log(await state.myPage.title())" - `} - -
    - -
    - -

    - Full Chrome DevTools Protocol access. Set breakpoints, step through - code, inspect variables at runtime. Live-edit page scripts and CSS - without reloading. -

    - - {dedent` - # Set breakpoints and debug - playwriter -s 1 -e "state.cdp = await getCDPSession({ page }); state.dbg = createDebugger({ cdp: state.cdp }); await state.dbg.enable()" - playwriter -s 1 -e "state.scripts = await state.dbg.listScripts({ search: 'app' }); console.log(state.scripts.map(s => s.url))" - playwriter -s 1 -e "await state.dbg.setBreakpoint({ file: state.scripts[0].url, line: 42 })" - - # Live edit page code - playwriter -s 1 -e "state.editor = createEditor({ cdp: state.cdp }); await state.editor.enable()" - playwriter -s 1 -e "await state.editor.edit({ url: 'https://example.com/app.js', oldString: 'const DEBUG = false', newString: 'const DEBUG = true' })" - `} - -

    - Edits are in-memory and persist until the page reloads. Useful for - toggling debug flags, patching broken code, or testing quick fixes - without touching source files. The editor also supports{" "} - grep across all loaded scripts. -

    - - - Breakpoints, stepping, variable inspection — from the CLI. - - -
    - -
    - -

    - Intercept requests and responses to reverse-engineer APIs, scrape - data, or debug network issues. Store captured data in{" "} - state and analyze across calls. -

    - - {dedent` - # Start intercepting - playwriter -s 1 -e "state.responses = []; page.on('response', async res => { if (res.url().includes('/api/')) { try { state.responses.push({ url: res.url(), status: res.status(), body: await res.json() }); } catch {} } })" - - # Trigger actions, then analyze - playwriter -s 1 -e "await page.click('button.load-more')" - playwriter -s 1 -e "console.log('Captured', state.responses.length, 'API calls'); state.responses.forEach(r => console.log(r.status, r.url.slice(0, 80)))" - - # Replay an API call directly - playwriter -s 1 -e "const data = await page.evaluate(async (url) => { const res = await fetch(url); return res.json(); }, state.responses[0].url); console.log(data)" - `} - -

    - This is faster than scrolling through DOM. Capture the real API - calls, inspect their schemas, and replay them with different - parameters. Works for pagination, authenticated endpoints, and - anything behind JavaScript rendering. -

    - -
    - -
    - -

    - Record the active tab as video using{" "} - chrome.tabCapture. The recording runs in - the extension context, so it survives page navigation. Video is saved - as MP4. -

    - - {dedent` - # Start recording - playwriter -s 1 -e "await startRecording({ page, outputPath: './recording.mp4', frameRate: 30 })" - - # Navigate, interact — recording continues - playwriter -s 1 -e "await page.click('a'); await page.waitForLoadState('domcontentloaded')" - playwriter -s 1 -e "await page.goBack()" - - # Stop and save - playwriter -s 1 -e "const { path, duration, size } = await stopRecording({ page }); console.log(path, duration + 'ms', size + ' bytes')" - `} - -

    - Unlike getDisplayMedia, this approach - persists across navigations because the extension holds the{" "} - MediaRecorder, not the page. You can also - check recording status with isRecording or - cancel without saving with cancelRecording. -

    - - - Native tab capture. 30–60fps. Survives navigation. - - -
    - -
    - -

    - How Playwriter compares to other browser automation approaches. -

    - - - - - - - -
    - -
    - -

    - Control Chrome on any machine from anywhere over the internet. - The relay runs on the host alongside Chrome. - A{" "} - traforo{" "} - tunnel exposes it through Cloudflare, giving you a secure public URL. - No VPN, no firewall rules, no port forwarding. -

    - - {dedent` - # On the host machine — start relay with tunnel - npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token - - # From anywhere — set env vars and use normally - export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev - export PLAYWRITER_TOKEN= - playwriter -s 1 -e "await page.goto('https://example.com')" - `} - -

    - Also works on a LAN without tunnels — just set{" "} - PLAYWRITER_HOST=192.168.1.10. Works for MCP - too — set PLAYWRITER_HOST and{" "} - PLAYWRITER_TOKEN in your MCP client env config. - Use cases: headless Mac mini, remote user support, - multi-machine automation, dev from a VM or devcontainer. -

    - -
    - -
    - -

    - Playwriter is local by default. The relay runs on{" "} - localhost:19988 and only accepts connections - from the extension. There's no remote server, no account, no - telemetry. -

    - - -
  • - Local only — WebSocket server binds to - localhost. Nothing leaves your machine. -
  • -
  • - Origin validation — only the Playwriter - extension origin is accepted. Browsers cannot spoof the Origin - header, so malicious websites cannot connect. -
  • -
  • - Explicit consent — only tabs where you - clicked the extension icon are controlled. No background access. -
  • -
  • - Visible automation — Chrome shows an - automation banner on controlled tabs. -
  • -
    - -
    - -
    - ); -}