Guides
Move from a local Chrome profile
Switch an account from a Chrome profile directory on your machine to a Browlix profile without touching the app's page code, one account at a time and with a way back.
Many apps open one local Chrome profile directory per account — launchPersistentContext("./chrome-profiles/acc-42") in Playwright, --user-data-dir elsewhere. Moving to Browlix changes only how the app gets that browser. The code that works with the page keeps receiving a BrowserContext and does not change.
Every snippet on this page is a region of examples/migrate-chrome/migrate.mts. Browlix runs that program twice on its Ubuntu and Windows lab machines against browlix.com: the first run moves the account, the second finds it already moved.
What changes and what does not
| Before | After | |
|---|---|---|
| Where the browser data lives | A folder on one machine | A Browlix profile, sealed and synced after each session |
| How the app gets a browser | launchPersistentContext(dir) | acquireSession → attach → connectOverCDP |
| Proxy, language, time zone, screen | Flags or nothing | Profile settings, applied and reported back by the browser |
Page code (whoIsSignedIn, posting, reading…) | Unchanged | Unchanged |
Browlix does not import a Chrome profile directory or its cookies. The account signs in once inside its new Browlix profile — by your app's own login code, or by a person in the web app's live view. Plan for that step, especially for accounts that need a code or a confirmation to sign in.
The app's own code
This is the part that stays the same. example.com stands in for the real site; your app reads the signed-in account from the site itself — a user ID or the account shown on the page, never the mere presence of a cookie.
/** The app's own code. It does not change in the move: it only needs a BrowserContext. */
async function whoIsSignedIn(context: BrowserContext): Promise<string | null> {
const page = context.pages()[0] ?? (await context.newPage());
await page.goto(SITE, { waitUntil: "domcontentloaded" });
return page.evaluate(() => localStorage.getItem("signed-in-account"));
}
async function signIn(context: BrowserContext, account: string): Promise<void> {
const page = context.pages()[0] ?? (await context.newPage());
await page.goto(SITE, { waitUntil: "domcontentloaded" });
await page.evaluate((a) => localStorage.setItem("signed-in-account", a), account);
}Before
/** Before: one local Chrome profile directory per account. */
async function openLocalChrome(account: string) {
const context = await chromium.launchPersistentContext(join(LOCAL_PROFILES, account), { headless: true });
return { context, close: () => context.close() };
}After
Once per account, create a profile and bind it to the account. Give the profile the same proxy the account used before (proxy_id in profiles.create) so the site keeps seeing the same network.
/** Once per account: a Browlix profile, bound to (app, account). Give it the proxy the account used before. */
async function bindAccount(account: string): Promise<{ profileId: string; created: boolean }> {
const found = (await bx.call("bindings.list", { query: { app_id: APP, external_account_id: account } })).data[0];
if (found) return { profileId: found.profile_id, created: false };
const profile = await bx.call("profiles.create", { body: { profile: { name: `${APP} · ${account}`, config: { os: OS } } } });
const b = await bx.call("bindings.create", { body: { app_id: APP, external_account_id: account, profile_id: profile.data.profile_id } });
return { profileId: b.data.profile_id, created: true };
}Then, instead of launching a folder, hold the account's browser and attach Playwright to it. Run this on the machine whose Browlix worker opens the browser: CDP attach is same-machine.
/** After: the account's browser runs in Browlix; the app holds it while it works and attaches Playwright. */
async function openBrowlix(account: string, deviceId: string) {
const hold = await bx.acquireSession({ appId: APP, externalAccountId: account, holderRef: WORKER, deviceId, wait: { timeoutMs: 600_000 } });
const a = await bx.attach(hold.sessionId, { ttlSeconds: 60 });
const browser = await chromium.connectOverCDP(a.endpoint, { headers: a.headers });
const context = browser.contexts()[0] ?? fail("the browser has no default context");
return {
context,
sessionId: hold.sessionId,
close: async () => {
await browser.close(); // disconnects Playwright only
await hold.detach({ keepWarmSeconds: 0 });
await bx.stopSession(hold.sessionId);
await bx.waitForSession(hold.sessionId, { until: "closed", timeoutMs: 180_000 });
},
};
}close() stops the browser; data is sealed and stored after it closes. For an app that works on the account again soon, detach with keepWarmSeconds instead and let the next job reuse the open browser — see Hold a browser from your app.
Move one account
- Check the starting point. Open the local profile with the old code and confirm who is signed in.
- Bind the account to a new Browlix profile (with its proxy).
- Open it in Browlix. Expect nobody to be signed in: nothing was copied.
- Sign in once inside the Browlix profile.
- Verify the identity with the app's own check. If it is not the expected account, do not switch: fix the sign-in or trash the profile and try again — the account's live work still runs on the old directory.
- Switch the account's code path to
openBrowlixand let a later job reopen it: it should already be signed in. - Keep the old directory untouched until the account has run well on Browlix for as long as you need to be sure. Switching back means pointing the account at the old code path again.
Move a few accounts first, watch them, then the rest. Never let the old directory and the Browlix profile be used for the same account at the same time.
What the lab run checks
| Step | What happens |
|---|---|
local-before | The local directory is signed in as acc-42 |
bind | A new profile is bound to (migrate-sample, acc-42); on the second run the binding is reused |
first-open | First run: nobody is signed in. Second run: already acc-42 |
sign-in-once | Signs in only when needed |
verify-identity | The app's check returns acc-42, otherwise the run stops |
reopen | A new session on the same profile is still signed in |
rollback-ready | The local directory still opens signed in as acc-42 |
Run it
npm install
npm exec -- playwright install chromium-headless-shell # only for the "before" half
BROWLIX_API_KEY=bxk_… node migrate.mtsThe key needs device.read, profile.read, profile.create, profile.edit, session.read, session.start, session.stop and session.attach. BROWLIX_ACCOUNT, BROWLIX_APP_ID and LOCAL_PROFILES change the account, the app and the folder of local profiles.
Full program
package.json
{
"name": "browlix-migrate-chrome-sample",
"private": true,
"type": "module",
"description": "Move an account from a local Chrome profile directory to a Browlix profile without changing the app's page code (DV15).",
"scripts": {
"start": "node migrate.mts",
"check": "tsc -p ."
},
"dependencies": {
"@browlix/sdk": "https://browlix.com/api/public/sdk/v1/download/0.10.0",
"playwright": "^1.56.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}migrate.mts
/**
* Move one account from a local Chrome profile directory to a Browlix profile (DV15).
*
* before: the app opens ./chrome-profiles/<account> with Playwright
* after: the app holds the account's Browlix browser and attaches the same Playwright code
*
* One run: check the local profile → bind the account → open it in Browlix → sign in once (the
* local data is not copied) → check it is the right account → close → reopen (still signed in) →
* the local directory is untouched, so switching back stays possible.
*
* Run it on the machine whose Browlix worker opens the browser (CDP attach is same-machine):
* BROWLIX_API_KEY=bxk_… node migrate.mts
* Optional: BROWLIX_DEVICE_ID, BROWLIX_APP_ID (default migrate-sample), BROWLIX_ACCOUNT (default acc-42),
* LOCAL_PROFILES (default ./chrome-profiles). The last line is `RESULT {json}`; exit code 0 = every step passed.
*/
import { hostname } from "node:os";
import { join } from "node:path";
import { Browlix, BrowlixError } from "@browlix/sdk";
import { chromium, type BrowserContext } from "playwright";
const apiKey = process.env["BROWLIX_API_KEY"];
if (!apiKey) {
console.error("Set BROWLIX_API_KEY to a key with 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"] ?? "migrate-sample";
const ACCOUNT = process.env["BROWLIX_ACCOUNT"] ?? "acc-42";
const LOCAL_PROFILES = process.env["LOCAL_PROFILES"] ?? "./chrome-profiles";
const OS = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux";
const WORKER = `migrate-${hostname().toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`;
/** example.com stands in for the real site; your app reads the account from the site itself. */
const SITE = "https://example.com/";
const bx = new Browlix({ apiKey, ...(baseUrl ? { baseUrl } : {}) });
const result: Record<string, unknown> = { app_id: APP, external_account_id: ACCOUNT, os: OS };
const steps: { step: string; ok: boolean; ms: number; error?: string }[] = [];
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;
}
}
// #region work
/** The app's own code. It does not change in the move: it only needs a BrowserContext. */
async function whoIsSignedIn(context: BrowserContext): Promise<string | null> {
const page = context.pages()[0] ?? (await context.newPage());
await page.goto(SITE, { waitUntil: "domcontentloaded" });
return page.evaluate(() => localStorage.getItem("signed-in-account"));
}
async function signIn(context: BrowserContext, account: string): Promise<void> {
const page = context.pages()[0] ?? (await context.newPage());
await page.goto(SITE, { waitUntil: "domcontentloaded" });
await page.evaluate((a) => localStorage.setItem("signed-in-account", a), account);
}
// #endregion
// #region before
/** Before: one local Chrome profile directory per account. */
async function openLocalChrome(account: string) {
const context = await chromium.launchPersistentContext(join(LOCAL_PROFILES, account), { headless: true });
return { context, close: () => context.close() };
}
// #endregion
// #region bind
/** Once per account: a Browlix profile, bound to (app, account). Give it the proxy the account used before. */
async function bindAccount(account: string): Promise<{ profileId: string; created: boolean }> {
const found = (await bx.call("bindings.list", { query: { app_id: APP, external_account_id: account } })).data[0];
if (found) return { profileId: found.profile_id, created: false };
const profile = await bx.call("profiles.create", { body: { profile: { name: `${APP} · ${account}`, config: { os: OS } } } });
const b = await bx.call("bindings.create", { body: { app_id: APP, external_account_id: account, profile_id: profile.data.profile_id } });
return { profileId: b.data.profile_id, created: true };
}
// #endregion
// #region after
/** After: the account's browser runs in Browlix; the app holds it while it works and attaches Playwright. */
async function openBrowlix(account: string, deviceId: string) {
const hold = await bx.acquireSession({ appId: APP, externalAccountId: account, holderRef: WORKER, deviceId, wait: { timeoutMs: 600_000 } });
const a = await bx.attach(hold.sessionId, { ttlSeconds: 60 });
const browser = await chromium.connectOverCDP(a.endpoint, { headers: a.headers });
const context = browser.contexts()[0] ?? fail("the browser has no default context");
return {
context,
sessionId: hold.sessionId,
close: async () => {
await browser.close(); // disconnects Playwright only
await hold.detach({ keepWarmSeconds: 0 });
await bx.stopSession(hold.sessionId);
await bx.waitForSession(hold.sessionId, { until: "closed", timeoutMs: 180_000 });
},
};
}
// #endregion
async function thisMachine(): Promise<string> {
const fromEnv = process.env["BROWLIX_DEVICE_ID"];
if (fromEnv) return fromEnv;
const online = (await bx.call("devices.list")).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;
}
let open: { close: () => Promise<void>; sessionId?: string } | null = null;
let exitCode = 1;
try {
const deviceId = await step("machine", thisMachine);
// the starting point: the account is signed in inside its local Chrome directory
await step("local-before", async () => {
const local = await openLocalChrome(ACCOUNT);
open = local;
if ((await whoIsSignedIn(local.context)) === null) await signIn(local.context, ACCOUNT);
const who = await whoIsSignedIn(local.context);
await local.close();
open = null;
if (who !== ACCOUNT) fail(`the local profile is signed in as ${String(who)}`);
});
const bound = await step("bind", () => bindAccount(ACCOUNT));
Object.assign(result, { profile_id: bound.profileId, binding_created: bound.created });
// first open in Browlix: nothing from the local directory came along
const first = await step("first-open", async () => {
const b = await openBrowlix(ACCOUNT, deviceId);
open = b;
result["session_id"] = b.sessionId;
const who = await whoIsSignedIn(b.context);
result["signed_in_on_first_open"] = who;
return { b, who };
});
// sign in once inside the Browlix profile (or by hand in the web app's live view), then check the
// account is the expected one before switching: a cookie is not proof of who is signed in
await step("sign-in-once", async () => {
if (first.who === null) await signIn(first.b.context, ACCOUNT);
});
await step("verify-identity", async () => {
const who = await whoIsSignedIn(first.b.context);
if (who !== ACCOUNT) fail(`signed in as ${String(who)}, expected ${ACCOUNT}: do not switch this account`);
await first.b.close();
open = null;
});
// the profile keeps the sign-in: a later job opens it already signed in
await step("reopen", async () => {
const b = await openBrowlix(ACCOUNT, deviceId);
open = b;
result["reopen_session_id"] = b.sessionId;
const who = await whoIsSignedIn(b.context);
await b.close();
open = null;
if (who !== ACCOUNT) fail(`after reopening, signed in as ${String(who)}`);
});
// switching back is still possible: the old directory was never touched
await step("rollback-ready", async () => {
const local = await openLocalChrome(ACCOUNT);
open = local;
const who = await whoIsSignedIn(local.context);
await local.close();
open = null;
if (who !== ACCOUNT) fail(`the local profile changed: ${String(who)}`);
});
exitCode = 0;
} catch (e) {
if (!steps.some((x) => !x.ok)) {
result["error"] = String((e as Error).message ?? e);
console.log(`FAIL ${result["error"]}`);
}
await (open as { close: () => Promise<void> } | null)?.close().catch(() => undefined);
} finally {
result["steps"] = steps;
result["ok"] = exitCode === 0;
console.log(`RESULT ${JSON.stringify(result)}`);
process.exit(exitCode);
}