Skip to main content

Guides

Sample app

A complete app that binds an account, holds its browser, drives it with Playwright, survives a lost connection and a restart, lets go warm and stops — with the public SDK only.

View as Markdown

This is the program Browlix runs three times in a row on its Ubuntu and Windows lab machines against browlix.com. It uses nothing but the published @browlix/sdk and Playwright, so it is a working starting point for an app that posts, checks inboxes or scrapes with many accounts. The concepts are in Hold a browser from your app.

What one run does

StepWhat happens
machineFinds this machine: BROWLIX_DEVICE_ID, or the only online machine with this OS
bindReuses the binding for (app_id, external_account_id), or creates a profile and binds it
acquireacquireSession opens the account's browser and holds it; the SDK renews it in the background
readyThe session is ready on the binding's profile
workAttaches Playwright, opens example.com and bumps a counter stored in the profile
reconnectDrops the CDP connection, checks the browser kept running, attaches again
restartA restarted app with the same key and holder_ref gets renewed — same browser, same generation
fenceA second worker gets BUSY (holder_kind: app); once the hold lapses it takes the warm browser (reused, newer generation) and the stalled worker gets FENCED
detach-warmLets go with keep_warm_seconds; the browser stays open
reuse-warmThe next job reuses the warm browser at once
stopDetaches and stops the browser, waits for closed
eventsReads sessions.events for the session

Because the counter lives in the profile, it grows by one on every run: the account keeps its data between sessions.

Run it

Run it on the machine whose Browlix worker opens the browser — CDP attach is same-machine. Node 22.18 or later runs .mts directly.

bash
npm install
BROWLIX_API_KEY=bxk_… node consumer.mts

The key needs workspace.read, device.read, profile.read, profile.create, profile.edit, session.read, session.start, session.stop and session.attach. BROWLIX_APP_ID and BROWLIX_ACCOUNT change the app and account (defaults consumer-sample, demo-account-1). The last line is RESULT {json} with every step's time; the exit code is 0 only when every step passed.

Source

package.json

json
{
  "name": "browlix-consumer-sample",
  "private": true,
  "type": "module",
  "description": "An app with many accounts using Browlix through the public SDK only: bind, acquire, attach Playwright, recover, let go warm, stop (DV16).",
  "scripts": {
    "start": "node consumer.mts",
    "check": "tsc -p ."
  },
  "dependencies": {
    "@browlix/sdk": "https://browlix.com/api/public/sdk/v1/download/0.9.0",
    "playwright": "^1.56.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "typescript": "^5.6.0"
  }
}

consumer.mts

ts
/**
 * Browlix consumer sample (DV16): what an app with many accounts does, using only the public SDK.
 *
 *   bind → acquire → ready → attach Playwright → work → lose the connection → reattach →
 *   restart the app → keep the hold → lapse and be fenced → let go warm → reuse → stop
 *
 * Run it on the machine whose Browlix worker opens the browser (CDP attach is same-machine):
 *   BROWLIX_API_KEY=bxk_… node consumer.mts
 * Optional: BROWLIX_DEVICE_ID (this machine; otherwise the only online machine with this OS),
 * BROWLIX_APP_ID (default consumer-sample), BROWLIX_ACCOUNT (default demo-account-1).
 * The last line is `RESULT {json}`; the exit code is 0 only when every step passed.
 */
import { hostname } from "node:os";

import { Browlix, BrowlixError, type SessionHold } from "@browlix/sdk";
import { chromium, type Browser } from "playwright";

const apiKey = process.env["BROWLIX_API_KEY"];
if (!apiKey) {
  console.error("Set BROWLIX_API_KEY to a key with workspace.read, device.read, profile.read, profile.create, profile.edit, session.read, session.start, session.stop and session.attach.");
  process.exit(2);
}
const baseUrl = process.env["BROWLIX_API_BASE"];
const APP = process.env["BROWLIX_APP_ID"] ?? "consumer-sample";
const ACCOUNT = process.env["BROWLIX_ACCOUNT"] ?? "demo-account-1";
const OS = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux";
const WORKER = `consumer-${hostname().toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`;
const TTL = 30; // the shortest hold: the fencing step waits for it to lapse
const WARM = 120;

/** A fresh client is what a restarted app process has: no timers, no state, the same key. */
const client = () => new Browlix({ apiKey, ...(baseUrl ? { baseUrl } : {}) });
const bx = client();

const result: Record<string, unknown> = { app_id: APP, external_account_id: ACCOUNT, holder_ref: WORKER, os: OS };
const steps: { step: string; ok: boolean; ms: number; error?: string }[] = [];
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function fail(msg: string): never {
  throw new Error(msg);
}

async function step<T>(name: string, fn: () => Promise<T>): Promise<T> {
  const t0 = Date.now();
  try {
    const v = await fn();
    steps.push({ step: name, ok: true, ms: Date.now() - t0 });
    console.log(`ok   ${name} (${Date.now() - t0} ms)`);
    return v;
  } catch (e) {
    const error = e instanceof BrowlixError ? `${e.code}: ${e.message}` : String((e as Error).message ?? e);
    steps.push({ step: name, ok: false, ms: Date.now() - t0, error });
    console.log(`FAIL ${name}: ${error}`);
    throw e;
  }
}

async function thisMachine(): Promise<string> {
  const fromEnv = process.env["BROWLIX_DEVICE_ID"];
  if (fromEnv) return fromEnv;
  const { data } = await bx.call("devices.list");
  const online = data.filter((d) => d.os === OS && d.freshness === "current");
  if (online.length !== 1) fail(`found ${online.length} online ${OS} machines; set BROWLIX_DEVICE_ID to this machine's id`);
  return online[0]!.id;
}

/** One binding per (app, account): reuse it, or create a profile for the account and bind it. */
async function bindAccount(): Promise<{ bindingId: string; profileId: string; created: boolean }> {
  const find = async () => (await bx.call("bindings.list", { query: { app_id: APP, external_account_id: ACCOUNT } })).data[0];
  const existing = await find();
  if (existing) return { bindingId: existing.id, profileId: existing.profile_id, created: false };
  const profile = await bx.call("profiles.create", { body: { profile: { name: `${APP} · ${ACCOUNT}`, config: { os: OS } } } });
  try {
    const b = await bx.call("bindings.create", {
      body: { app_id: APP, external_account_id: ACCOUNT, profile_id: profile.data.profile_id, label: "Consumer sample" },
    });
    return { bindingId: b.data.id, profileId: b.data.profile_id, created: true };
  } catch (e) {
    // another worker bound the account first: use theirs and drop the spare profile
    if (!(e instanceof BrowlixError && e.code === "CONFLICT")) throw e;
    await bx.call("profiles.trash", { body: { ids: [profile.data.profile_id] } });
    const won = (await find()) ?? fail("CONFLICT but no binding found");
    return { bindingId: won.id, profileId: won.profile_id, created: false };
  }
}

async function connect(sessionId: string): Promise<Browser> {
  const a = await bx.attach(sessionId, { ttlSeconds: 60 });
  return chromium.connectOverCDP(a.endpoint, { headers: a.headers });
}

async function firstPage(browser: Browser) {
  const context = browser.contexts()[0] ?? fail("the browser has no default context");
  return context.pages()[0] ?? (await context.newPage());
}

async function lifecycle(sessionId: string): Promise<string> {
  return (await bx.call("sessions.get", { path: { id: sessionId } })).data.lifecycle;
}

const lost: string[] = [];
const onLost = (e: BrowlixError) => lost.push(`${e.code}:${String((e.info.details as { reason?: string } | undefined)?.reason ?? "")}`);

let sessionId = "";
let held: SessionHold | null = null;
let exitCode = 1;
try {
  const deviceId = await step("machine", thisMachine);
  result["device_id"] = deviceId;

  const binding = await step("bind", bindAccount);
  Object.assign(result, { binding_id: binding.bindingId, profile_id: binding.profileId, binding_created: binding.created });

  // 1. hold the account's browser: opened (or reused if a previous run left it warm)
  const hold = await step("acquire", () =>
    bx.acquireSession({ appId: APP, externalAccountId: ACCOUNT, holderRef: WORKER, deviceId, ttlSeconds: TTL, keepWarmSeconds: WARM, wait: { timeoutMs: 600_000 }, onLost }),
  );
  held = hold;
  sessionId = hold.sessionId;
  Object.assign(result, { session_id: sessionId, acquired: hold.acquired, generation: hold.generation });
  await step("ready", async () => {
    const s = (await bx.call("sessions.get", { path: { id: sessionId } })).data;
    if (s.lifecycle !== "ready") fail(`the session is ${s.lifecycle}`);
    if (s.profile.id !== binding.profileId) fail("acquire opened another profile than the binding's");
  });

  // 2. drive the page; the visit counter lives in the profile, so it grows run after run
  const counter = await step("work", async () => {
    const browser = await connect(sessionId);
    const page = await firstPage(browser);
    await page.goto("https://example.com/", { waitUntil: "domcontentloaded" });
    const title = await page.title();
    if (title !== "Example Domain") fail(`unexpected title ${title}`);
    const n = await page.evaluate(() => {
      const next = Number(localStorage.getItem("bx-consumer-runs") ?? "0") + 1;
      localStorage.setItem("bx-consumer-runs", String(next));
      return next;
    });
    await browser.close(); // disconnects Playwright; the browser keeps running
    return n;
  });
  result["visit_counter"] = counter;

  // 3. connection loss: the CDP link is gone, the browser and the hold are not
  await step("reconnect", async () => {
    if ((await lifecycle(sessionId)) !== "ready") fail("disconnecting ended the session");
    const browser = await connect(sessionId);
    const page = await firstPage(browser);
    if (!page.url().startsWith("https://example.com")) await page.goto("https://example.com/");
    const n = await page.evaluate(() => Number(localStorage.getItem("bx-consumer-runs") ?? "0"));
    await browser.close();
    if (n !== counter) fail(`after reconnecting the counter is ${n}, expected ${counter}`);
  });

  // 4. the app process dies without detaching and restarts: same key + holder_ref keeps the hold
  hold.stopRenewing();
  const restarted = await step("restart", async () => {
    const h = await client().acquireSession({ appId: APP, externalAccountId: ACCOUNT, holderRef: WORKER, deviceId, ttlSeconds: TTL, keepWarmSeconds: WARM, onLost });
    if (h.acquired !== "renewed" || h.sessionId !== sessionId || h.generation !== hold.generation) fail(`expected renewed ${sessionId}/${hold.generation}, got ${h.acquired} ${h.sessionId}/${h.generation}`);
    return h;
  });
  held = restarted;

  // 5. a stalled worker: its hold lapses, another worker takes the warm browser, the old one is fenced
  restarted.stopRenewing();
  const other = await step("fence", async () => {
    const busy = await client()
      .acquireSession({ appId: APP, externalAccountId: ACCOUNT, holderRef: `${WORKER}-b`, deviceId, ttlSeconds: TTL, keepWarmSeconds: WARM, autoRenew: false })
      .then(() => null)
      .catch((e: unknown) => e);
    if (!(busy instanceof BrowlixError && busy.code === "BUSY" && (busy.info.details as { holder_kind?: string } | undefined)?.holder_kind === "app"))
      fail(`a second worker should get BUSY (app) while the hold is current, got ${String(busy)}`);
    await sleep(Math.max(0, Date.parse(restarted.lease.expires_at) - Date.now()) + 2_000);
    const b = await client().acquireSession({ appId: APP, externalAccountId: ACCOUNT, holderRef: `${WORKER}-b`, deviceId, ttlSeconds: TTL, keepWarmSeconds: WARM, onLost });
    if (b.acquired !== "reused" || b.sessionId !== sessionId || b.generation <= restarted.generation) fail(`expected reused with a newer generation, got ${b.acquired} ${b.generation}`);
    const stale = await restarted.renew().then(() => null, (e: unknown) => e);
    if (!(stale instanceof BrowlixError && stale.code === "FENCED")) fail(`the lapsed worker should be FENCED, got ${String(stale)}`);
    result["fenced_reason"] = (stale.info.details as { reason?: string } | undefined)?.reason;
    return b;
  });
  held = other;
  result["generation_after_takeover"] = other.generation;

  // 6. let go without closing: the browser stays warm, and the next job reuses it at once
  await step("detach-warm", async () => {
    const lease = await other.detach({ keepWarmSeconds: WARM });
    if (lease.state !== "detached") fail(`detach left the lease ${lease.state}`);
    if ((await lifecycle(sessionId)) !== "ready") fail("detaching stopped the browser");
  });
  const again = await step("reuse-warm", async () => {
    const h = await client().acquireSession({ appId: APP, externalAccountId: ACCOUNT, holderRef: WORKER, deviceId, ttlSeconds: TTL, keepWarmSeconds: WARM, onLost });
    if (h.acquired !== "reused" || h.sessionId !== sessionId) fail(`expected the warm browser to be reused, got ${h.acquired} ${h.sessionId}`);
    return h;
  });
  held = again;
  result["generation_final"] = again.generation;

  // 7. done for today: detach and stop the browser
  await step("stop", async () => {
    await again.detach({ keepWarmSeconds: 0 });
    held = null;
    await bx.stopSession(sessionId);
    await bx.waitForSession(sessionId, { until: "closed", timeoutMs: 180_000 });
  });

  // 8. the event log the app would follow instead of (or next to) webhooks
  await step("events", async () => {
    const r = await bx.call("sessions.events", { query: { session_id: sessionId, limit: 200 } });
    const kinds = r.data.map((e) => e.kind);
    result["events"] = kinds.reduce<Record<string, number>>((m, k) => ({ ...m, [k]: (m[k] ?? 0) + 1 }), {});
    if (kinds.filter((k) => k === "lease.acquired").length < 3) fail(`expected three lease.acquired events, got ${JSON.stringify(result["events"])}`);
  });
  exitCode = 0;
} catch (e) {
  // a failed step is already in `steps`; anything else is reported here. Leave nothing running behind.
  if (!steps.some((x) => !x.ok)) {
    result["error"] = String((e as Error).message ?? e);
    console.log(`FAIL ${result["error"]}`);
  }
  try {
    if (held) await held.detach({ keepWarmSeconds: 0 }).catch(() => undefined);
    if (sessionId && !["closed", "failed"].includes(await lifecycle(sessionId))) await bx.stopSession(sessionId);
  } catch {
    /* best effort */
  }
} finally {
  result["holds_lost"] = lost;
  result["steps"] = steps;
  result["ok"] = exitCode === 0;
  console.log(`RESULT ${JSON.stringify(result)}`);
  process.exit(exitCode);
}