cdpfleet Docs GitHub Dashboard

Cases / #58 · 2026-10-17 · Medium

Do it by hand in live view, then let your code finish in the same browser

Launch a browser with `start_url` and no client, drive it through a control-mode live link — address bar, Tab, typing — then connect your code: it finds the person's page in `contexts()[0]`, reads what they typed and submits the form. The watched session outlived its 30 s idle timeout; an unwatched twin ended at 30 s.

Chromium

Run on production on 2026-10-17: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

The problem

Some steps are easier by hand: finding out why a page blocks your scraper, clicking through a consent wall, typing a one-time code, or just trying a browser and proxy before writing any code. With Launch interactive a person does those steps in a live view, and the script picks up exactly where they stopped — same browser, same cookies, same page — instead of replaying everything from scratch. This case does that end to end over the API, which is also how the dashboard's "Try it live" works.

What we used, and why

WhatWhy
start_url (launch option)The node opens this page itself, so the session runs before any client connects. Headless Chromium needs live_view: true to be watchable.
POST https://<wsUrl host>/admin/session/live with mode: "control"A single-use live link. For headless sessions it is a screencast WebSocket: JPEG frames in, and navigate, key and text messages out.
inactivity_timeout: "30s"Short on purpose, to see what counts as activity: input from the person, and an open viewer.
browser.contexts()[0].pages()Where your code finds the page the person was on when it connects to wsUrl.
GET /v1/me/sessionsThe account API's history: how long each session ran and why it ended.
proxyYour own proxy; a few KB of traffic.

How it works

  1. Launch two identical sessions with start_url: https://httpbin.org/ and a 30 s inactivity_timeout; open a control-mode live link for the first one only.
  2. In the live view, go to the order form from the address bar (navigate) and fill the first three fields from the keyboard: Tab, then type. In the programs this person is a scripted stand-in sending the same messages the dashboard's viewer sends.
  3. Stop all input and just watch for 45 s.
  4. Try to connect to the unwatched twin, then connect to the watched session and list the pages in its default context.
  5. Read the three typed fields, pick a size and a topping, set a delivery time and submit — httpbin echoes the form back. Close, and read both sessions from the history.

The code

The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:

// npm install [email protected] ws
// env: CDPFLEET_API_KEY, PROXY_URL (your own proxy; a few KB of traffic)
import { chromium } from 'playwright';
import WebSocket from 'ws';

const KEY = process.env.CDPFLEET_API_KEY;
const PROXY = process.env.PROXY_URL;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async function launch(engine, body) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
      method: 'POST',
      headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
      body: JSON.stringify(body),
    });
    if (res.ok) return res.json();
    if (res.status !== 503 || attempt === 5) throw new Error(`launch ${res.status} ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 5) * 1000);
  }
}

// 1. Launch with a start page and no client: the browser opens it by itself. A twin session
//    with the same options gets no viewer at all, to compare with.
const body = {
  proxy: PROXY, headless: true, live_view: true, record: false,
  start_url: 'https://httpbin.org/', inactivity_timeout: '30s',
};
const session = await launch('chromium', body);
const twin = await launch('chromium', body);

// 2. A live link in control mode — what "Launch interactive" in the dashboard opens for you.
const res = await fetch(`https://${new URL(session.wsUrl).host}/admin/session/live`, {
  method: 'POST',
  headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
  body: JSON.stringify({ session_id: session.sessionId, mode: 'control' }),
});
const live = await res.json();

// The viewer: JPEG frames (binary) and JSON messages; "pages" says which URL is on screen.
const viewer = new WebSocket(live.live_url);
const seen = [];
let frames = 0;
viewer.on('message', (data, binary) => {
  if (binary) { frames++; return; }
  const m = JSON.parse(data);
  if (m.type === 'pages') {
    const url = m.pages.find((p) => p.targetId === m.current)?.url;
    if (url && seen.at(-1) !== url) seen.push(url);
  }
});
await new Promise((ok, fail) => { viewer.once('open', ok); viewer.once('error', fail); });
const send = (m) => viewer.send(JSON.stringify(m));
const waitForUrl = async (part) => { for (let i = 0; i < 60 && !seen.some((u) => u.includes(part)); i++) await sleep(500); return seen.some((u) => u.includes(part)); };
const key = (k) => { send({ type: 'key', action: 'keyDown', key: k, code: k }); send({ type: 'key', action: 'keyUp', key: k, code: k }); };

// 3. The person (here a scripted stand-in): goes to the form from the address bar, then fills
//    the first three fields from the keyboard — Tab to a field, type.
const startShown = await waitForUrl('httpbin.org');
send({ type: 'navigate', url: 'https://httpbin.org/forms/post' });
const formShown = await waitForUrl('/forms/post');
await sleep(1500);
for (const text of ['Ada Lovelace', '+44 20 7946 0000', '[email protected]']) {
  key('Tab');
  await sleep(200);
  send({ type: 'text', text });
  await sleep(300);
}
// 4. Then only watches — no input for 45 s, past the 30 s inactivity_timeout.
await sleep(45000);

// The unwatched twin has had no input and no viewer for as long: it should be gone.
let twinAlive = true;
try {
  await (await chromium.connect(twin.wsUrl, { headers: { 'x-api-key': KEY }, timeout: 15000 })).close();
} catch {
  twinAlive = false;
}

// 5. Your code joins the same browser and finds the person's page in the default context.
const browser = await chromium.connect(session.wsUrl, { headers: { 'x-api-key': KEY } });
const out = {
  live_protocol: live.protocol, start_page_shown: startShown, form_shown: formShown, frames_while_by_hand: frames,
  watched_session_alive_after_45s: browser.isConnected(), unwatched_twin_alive_after_45s: twinAlive,
};
try {
  const pages = browser.contexts()[0].pages();
  const page = pages.find((p) => p.url().includes('/forms/post'));
  out.pages_found = pages.map((p) => p.url());
  out.typed_by_hand = {
    custname: await page.inputValue('input[name=custname]'),
    custtel: await page.inputValue('input[name=custtel]'),
    custemail: await page.inputValue('input[name=custemail]'),
  };
  // 6. The code finishes the job: the choices a script makes reliably, then submit.
  await page.check('input[name=size][value=medium]');
  await page.check('input[name=topping][value=cheese]');
  await page.fill('input[name=delivery]', '19:30');
  await Promise.all([page.waitForURL('**/post'), page.click('form button')]);
  const echo = JSON.parse(await page.textContent('body'));
  out.submitted = echo.form;
  await sleep(1500);
  out.viewer_saw = seen;
} finally {
  viewer.close();
  await browser.close();
}
// 7. The account API's session history: how long each session ran and why it ended.
async function history(sessionId) {
  for (let i = 0; i < 30; i++) {
    const res = await fetch(`https://cdpfleet.com/v1/me/sessions?limit=50&from=${new Date(Date.now() - 3600000).toISOString()}`, { headers: { 'x-api-key': KEY } });
    const row = (await res.json()).sessions.find((s) => s.session_id === sessionId);
    if (row?.ended_at) return { seconds: Math.round((Date.parse(row.ended_at) - Date.parse(row.started_at)) / 1000), end_reason: row.end_reason };
    await sleep(2000);
  }
  return null;
}
out.watched_session = await history(session.sessionId);
out.unwatched_twin = await history(twin.sessionId);
console.log(JSON.stringify(out, null, 2));

What we got

WhatResult
Live link protocolscreencast
Pages seen by the viewer/ → /forms/post → /post
Typed in the live view (read by code)Ada Lovelace · +44 20 7946 0000 · [email protected]
Submitted by codesize medium, topping cheese, delivery 19:30
Watched session after 45 s without inputalive
Unwatched twin after 45 sended
Watched session ran / ended because68 s / client_disconnected
Unwatched twin ran30 s

From the Node.js run on 2026-10-17. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.

Raw output (Node.js)
{
  "live_protocol": "screencast",
  "start_page_shown": true,
  "form_shown": true,
  "frames_while_by_hand": 142,
  "watched_session_alive_after_45s": true,
  "unwatched_twin_alive_after_45s": false,
  "pages_found": [
    "https://httpbin.org/forms/post"
  ],
  "typed_by_hand": {
    "custname": "Ada Lovelace",
    "custtel": "+44 20 7946 0000",
    "custemail": "[email protected]"
  },
  "submitted": {
    "comments": "",
    "custemail": "[email protected]",
    "custname": "Ada Lovelace",
    "custtel": "+44 20 7946 0000",
    "delivery": "19:30",
    "size": "medium",
    "topping": "cheese"
  },
  "viewer_saw": [
    "https://httpbin.org/",
    "https://httpbin.org/forms/post",
    "https://httpbin.org/post"
  ],
  "watched_session": {
    "seconds": 68,
    "end_reason": "client_disconnected"
  },
  "unwatched_twin": {
    "seconds": 30,
    "end_reason": "node_closed"
  }
}

Takeaways