import path from "node:path"; import fs from "node:fs"; import { chromium, type BrowserContext, type Page, type CDPSession } from "playwright"; import { createLogger, newId, nowIso, type Platform, type SessionInfo } from "@src/shared"; import type { EventBus } from "@src/events"; const log = createLogger("browser"); export interface SessionOptions { platform: Platform; accountAlias: string; profilesDir: string; headless: boolean; channel?: string; // "chromium" | "chrome" | "msedge" bus?: EventBus; viewport?: { width: number; height: number }; } /** * One persistent Chromium profile per (platform, account) — §7/§8/§65. * Authentication is never automated: a human logs in once (`login` command); the profile keeps the cookies. */ export class SocialBrowserSession { readonly sessionId = newId("sess"); readonly platform: Platform; readonly accountAlias: string; readonly profilePath: string; private context?: BrowserContext; private page?: Page; private cdp?: CDPSession; private info: SessionInfo; private readonly opts: SessionOptions; constructor(opts: SessionOptions) { this.opts = opts; this.platform = opts.platform; this.accountAlias = opts.accountAlias; this.profilePath = path.join(opts.profilesDir, `${opts.platform}-${opts.accountAlias}`); this.info = { session_id: this.sessionId, platform: this.platform, account_alias: this.accountAlias, profile_path: this.profilePath, started_at: nowIso(), navigation_depth: 0, health: "starting", }; } get id(): string { return this.sessionId; } hasProfile(): boolean { return fs.existsSync(path.join(this.profilePath, "Default")) || fs.existsSync(path.join(this.profilePath, "Local State")); } async start(): Promise { fs.mkdirSync(this.profilePath, { recursive: true }); const channel = this.opts.channel && this.opts.channel !== "chromium" ? this.opts.channel : undefined; this.context = await chromium.launchPersistentContext(this.profilePath, { headless: this.opts.headless, channel, viewport: this.opts.viewport ?? { width: 1380, height: 900 }, locale: "fr-CA", timezoneId: "America/Toronto", args: ["--disable-blink-features=AutomationControlled", "--autoplay-policy=no-user-gesture-required"], ignoreDefaultArgs: ["--enable-automation"], }); this.context.setDefaultTimeout(20_000); this.page = this.context.pages()[0] ?? (await this.context.newPage()); // Close extra tabs the platform may open; we keep a single-tab runtime. this.context.on("page", (p) => { if (p !== this.page) p.close().catch(() => {}); }); this.page.on("crash", () => { this.info.health = "crashed"; log.error("page crashed", { session: this.sessionId }); }); this.cdp = await this.context.newCDPSession(this.page); await this.cdp.send("Network.enable").catch(() => {}); this.info.health = "healthy"; log.info("session started", { session: this.sessionId, platform: this.platform, profile: this.profilePath, headless: this.opts.headless }); this.opts.bus?.emit({ event_type: "SESSION_STARTED", platform: this.platform, session_id: this.sessionId, payload: { account_alias: this.accountAlias, profile_path: this.profilePath, headless: this.opts.headless }, }); } async stop(): Promise { this.info.health = "stopped"; await this.context?.close().catch(() => {}); this.opts.bus?.emit({ event_type: "SESSION_ENDED", platform: this.platform, session_id: this.sessionId, payload: { ...this.info } }); log.info("session stopped", { session: this.sessionId }); } getPage(): Page { if (!this.page) throw new Error("session not started"); return this.page; } getContext(): BrowserContext { if (!this.context) throw new Error("session not started"); return this.context; } getCdp(): CDPSession | undefined { return this.cdp; } getInfo(): SessionInfo { return { ...this.info, current_url: this.page?.url() }; } setHealth(h: SessionInfo["health"]): void { this.info.health = h; } recordAction(label: string, depthDelta = 0): void { this.info.last_action = label; this.info.navigation_depth = Math.max(0, this.info.navigation_depth + depthDelta); } async navigate(url: string): Promise { const page = this.getPage(); await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 }); this.info.current_url = page.url(); } }