Hướng dẫn
App mẫu
Một app hoàn chỉnh gắn tài khoản, giữ trình duyệt, điều khiển bằng Playwright, chịu được mất kết nối và khởi động lại, nhả mà vẫn giữ ấm rồi dừng — chỉ dùng SDK công khai.
Đây là chương trình Browlix chạy ba lượt liên tiếp trên máy lab Ubuntu và Windows với browlix.com. Nó chỉ dùng @browlix/sdk đã phát hành và Playwright, nên là điểm khởi đầu chạy được cho app đăng bài, kiểm tra hộp thư hay thu thập dữ liệu với nhiều tài khoản. Khái niệm nằm ở App giữ trình duyệt.
Một lượt chạy làm gì
| Bước | Việc xảy ra |
|---|---|
machine | Tìm máy này: BROWLIX_DEVICE_ID, hoặc máy duy nhất đang trực tuyến cùng hệ điều hành |
bind | Dùng lại liên kết của (app_id, external_account_id), hoặc tạo hồ sơ rồi gắn |
acquire | acquireSession mở trình duyệt của tài khoản và giữ nó; SDK tự gia hạn ở nền |
ready | Phiên ready trên đúng hồ sơ của liên kết |
work | Gắn Playwright, mở example.com và tăng một bộ đếm lưu trong hồ sơ |
reconnect | Ngắt kết nối CDP, kiểm trình duyệt vẫn chạy, gắn lại |
restart | App khởi động lại với cùng khoá và holder_ref nhận renewed — cùng trình duyệt, cùng thế hệ |
fence | Worker thứ hai nhận BUSY (holder_kind: app); khi lượt giữ hết hạn nó lấy trình duyệt đang ấm (reused, thế hệ mới hơn) và worker bị treo nhận FENCED |
detach-warm | Nhả với keep_warm_seconds; trình duyệt vẫn mở |
reuse-warm | Việc kế tiếp dùng lại ngay trình duyệt đang ấm |
stop | Nhả và dừng trình duyệt, chờ closed |
events | Đọc sessions.events của phiên |
Vì bộ đếm nằm trong hồ sơ, nó tăng thêm một sau mỗi lượt: tài khoản giữ dữ liệu giữa các phiên.
Chạy
Chạy trên máy có worker Browlix mở trình duyệt — gắn CDP chỉ trong cùng máy. Node 22.18 trở lên chạy thẳng tệp .mts.
npm install
BROWLIX_API_KEY=bxk_… node consumer.mtsKhoá cần workspace.read, device.read, profile.read, profile.create, profile.edit, session.read, session.start, session.stop và session.attach. BROWLIX_APP_ID và BROWLIX_ACCOUNT đổi app và tài khoản (mặc định consumer-sample, demo-account-1). Dòng cuối là RESULT {json} với thời gian từng bước; mã thoát là 0 chỉ khi mọi bước đạt.
Mã nguồn
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);
}