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.
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
| Step | What happens |
|---|---|
machine | Finds this machine: BROWLIX_DEVICE_ID, or the only online machine with this OS |
bind | Reuses the binding for (app_id, external_account_id), or creates a profile and binds it |
acquire | acquireSession opens the account's browser and holds it; the SDK renews it in the background |
ready | The session is ready on the binding's profile |
work | Attaches Playwright, opens example.com and bumps a counter stored in the profile |
reconnect | Drops the CDP connection, checks the browser kept running, attaches again |
restart | A restarted app with the same key and holder_ref gets renewed — same browser, same generation |
fence | A 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-warm | Lets go with keep_warm_seconds; the browser stays open |
reuse-warm | The next job reuses the warm browser at once |
stop | Detaches and stops the browser, waits for closed |
events | Reads 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.
npm install
BROWLIX_API_KEY=bxk_… node consumer.mtsThe 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
{
"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
/**
* 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);
}