# 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](https://browlix.com/en/docs/app-sessions).

## 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.

```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);
}
```
