| 1 | // @effect-diagnostics nodeBuiltinImport:off - Owns Playwright resources outside the Effect runtime. |
| 2 | import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; |
| 3 | import { constVoid } from "effect/Function"; |
| 4 | import * as NodeFSP from "node:fs/promises"; |
| 5 | import * as NodeModule from "node:module"; |
| 6 | import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity"; |
| 7 | import * as NodePath from "node:path"; |
| 8 | import type { Browser, BrowserContext, CDPSession } from "playwright-core"; |
| 9 | |
| 10 | import { sandboxDisabled } from "./PreviewBrowserHost.ts"; |
| 11 | |
| 12 | // Playwright needs its files on disk. createRequire also resolves it from a Node SEA executable. |
| 13 | const requirePlaywright = NodeModule.createRequire(import.meta.url); |
| 14 | const loadPlaywright = () => |
| 15 | requirePlaywright("playwright-core") as typeof import("playwright-core"); |
| 16 | |
| 17 | interface Options { |
| 18 | readonly profilesDir: string; |
| 19 | /** The shared headless browser's executable, installing it on first use. */ |
| 20 | readonly executable: () => Promise<string>; |
| 21 | /** Replaces a failed launch's error with the host setup it is missing, if any. */ |
| 22 | readonly diagnose?: (executable: string, cause: unknown) => Promise<unknown>; |
| 23 | readonly env?: NodeJS.ProcessEnv; |
| 24 | readonly onContextClose?: (context: BrowserContext) => void; |
| 25 | } |
| 26 | |
| 27 | /** |
| 28 | * Presents a headless page as plain Chrome. Applies to the page, its frames, |
| 29 | * and its workers; a popup gets its own call once it becomes a tab. |
| 30 | */ |
| 31 | export const presentAsChrome = async ( |
| 32 | cdp: CDPSession, |
| 33 | host: { readonly platform: NodeJS.Platform; readonly arch: string }, |
| 34 | ) => { |
| 35 | const { userAgent } = await cdp.send("Browser.getVersion"); |
| 36 | const identity = browserChromeIdentity(userAgent, host); |
| 37 | if (identity) await cdp.send("Emulation.setUserAgentOverride", identity); |
| 38 | }; |
| 39 | |
| 40 | /** Persistent human profiles keep their storage; isolated agents share a browser, never a context. */ |
| 41 | export class ServerBrowserContexts { |
| 42 | private readonly options: Options; |
| 43 | private readonly contexts = new Map<string, Promise<BrowserContext>>(); |
| 44 | /** Profile clears in progress; a context for that profile opens after its storage is gone. */ |
| 45 | private readonly clearing = new Map<string, Promise<void>>(); |
| 46 | private browser: Promise<Browser> | undefined; |
| 47 | private closing: Promise<void> | undefined; |
| 48 | |
| 49 | constructor(options: Options) { |
| 50 | this.options = options; |
| 51 | } |
| 52 | |
| 53 | private async launchOptions() { |
| 54 | const executablePath = await this.options.executable(); |
| 55 | const env = this.options.env ?? process.env; |
| 56 | return { |
| 57 | executablePath, |
| 58 | env, |
| 59 | // People drive these tabs, so they must not announce automation (navigator.webdriver). |
| 60 | args: [ |
| 61 | "--disable-gpu", |
| 62 | "--force-device-scale-factor=2", |
| 63 | "--disable-blink-features=AutomationControlled", |
| 64 | ], |
| 65 | ignoreDefaultArgs: ["--enable-automation"], |
| 66 | headless: true, |
| 67 | // Only an explicit operator opt-out disables sandboxing. Launch errors never do. |
| 68 | chromiumSandbox: !sandboxDisabled(env), |
| 69 | }; |
| 70 | } |
| 71 | |
| 72 | private async launch<A>(options: { readonly executablePath: string }, start: () => Promise<A>) { |
| 73 | try { |
| 74 | return await start(); |
| 75 | } catch (cause) { |
| 76 | throw (await this.options.diagnose?.(options.executablePath, cause)) ?? cause; |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | private sharedBrowser() { |
| 81 | if (!this.browser) { |
| 82 | const launched = this.launchOptions().then(async (options) => { |
| 83 | const { chromium } = loadPlaywright(); |
| 84 | const browser = await this.launch(options, () => chromium.launch(options)); |
| 85 | browser.on("disconnected", () => { |
| 86 | if (this.browser === launched) this.browser = undefined; |
| 87 | }); |
| 88 | return browser; |
| 89 | }); |
| 90 | this.browser = launched; |
| 91 | void launched.catch(() => { |
| 92 | if (this.browser === launched) this.browser = undefined; |
| 93 | }); |
| 94 | } |
| 95 | return this.browser; |
| 96 | } |
| 97 | |
| 98 | contextFor(profileId: string, isolationKey?: string): Promise<BrowserContext> { |
| 99 | if (this.closing) return Promise.reject(new Error("The preview browser is closed.")); |
| 100 | const key = JSON.stringify([profileId, isolationKey ?? null]); |
| 101 | const cached = this.contexts.get(key); |
| 102 | if (cached) return cached; |
| 103 | const cleared = this.clearing.get(key)?.catch(constVoid) ?? Promise.resolve(); |
| 104 | const pending = cleared |
| 105 | .then(() => this.createContext(profileId, isolationKey)) |
| 106 | .then(async (context) => { |
| 107 | context.on("close", () => { |
| 108 | if (this.contexts.get(key) === pending) this.contexts.delete(key); |
| 109 | this.options.onContextClose?.(context); |
| 110 | }); |
| 111 | for (const page of context.pages()) await page.close().catch(constVoid); |
| 112 | if (this.closing) { |
| 113 | await context.close().catch(constVoid); |
| 114 | throw new Error("The preview browser is closed."); |
| 115 | } |
| 116 | return context; |
| 117 | }); |
| 118 | this.contexts.set(key, pending); |
| 119 | void pending.catch(() => { |
| 120 | if (this.contexts.get(key) === pending) this.contexts.delete(key); |
| 121 | }); |
| 122 | return pending; |
| 123 | } |
| 124 | |
| 125 | private async createContext(profileId: string, isolationKey?: string) { |
| 126 | const contextOptions = { viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 }; |
| 127 | if (isolationKey !== undefined || profileId === INCOGNITO_BROWSER_PROFILE_ID) { |
| 128 | const browser = await this.sharedBrowser(); |
| 129 | return browser.newContext(contextOptions); |
| 130 | } |
| 131 | const directory = this.profileDirectory(profileId); |
| 132 | const options = await this.launchOptions(); |
| 133 | const { chromium } = loadPlaywright(); |
| 134 | await NodeFSP.mkdir(directory, { recursive: true }); |
| 135 | return this.launch(options, () => |
| 136 | chromium.launchPersistentContext(directory, { ...options, ...contextOptions }), |
| 137 | ); |
| 138 | } |
| 139 | |
| 140 | private profileDirectory(profileId: string) { |
| 141 | const encoded = encodeURIComponent(profileId); |
| 142 | return NodePath.join( |
| 143 | this.options.profilesDir, |
| 144 | profileId === "." || profileId === ".." ? encoded.replaceAll(".", "%2E") : encoded, |
| 145 | ); |
| 146 | } |
| 147 | |
| 148 | /** A throwaway page in the shared browser, for work that must not touch a tab's own storage. */ |
| 149 | async scratchPage() { |
| 150 | if (this.closing) throw new Error("The preview browser is closed."); |
| 151 | const context = await (await this.sharedBrowser()).newContext(); |
| 152 | const page = await context.newPage(); |
| 153 | page.once("close", () => void context.close().catch(constVoid)); |
| 154 | return page; |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * Drives a page the desktop app renders, through the CDP endpoint its relay |
| 159 | * serves. The page keeps the desktop's storage, size, and window. |
| 160 | */ |
| 161 | async connectDesktopPage(endpoint: string) { |
| 162 | const { chromium } = loadPlaywright(); |
| 163 | const browser = await chromium.connectOverCDP(endpoint, { timeout: 15_000 }); |
| 164 | const page = browser.contexts().flatMap((context) => context.pages())[0]; |
| 165 | if (!page) { |
| 166 | await browser.close().catch(constVoid); |
| 167 | throw new Error("The desktop tab has no page."); |
| 168 | } |
| 169 | return { browser, page }; |
| 170 | } |
| 171 | |
| 172 | /** Closes a human profile's persistent context, ending its tabs, then deletes its storage. */ |
| 173 | async clearProfile(profileId: string) { |
| 174 | if (profileId === INCOGNITO_BROWSER_PROFILE_ID) return; |
| 175 | const key = JSON.stringify([profileId, null]); |
| 176 | const clearing = (async () => { |
| 177 | const pending = this.contexts.get(key); |
| 178 | if (pending) { |
| 179 | this.contexts.delete(key); |
| 180 | const context = await pending.catch(() => undefined); |
| 181 | await context?.close(); |
| 182 | } |
| 183 | await NodeFSP.rm(this.profileDirectory(profileId), { recursive: true, force: true }); |
| 184 | })(); |
| 185 | this.clearing.set(key, clearing); |
| 186 | try { |
| 187 | await clearing; |
| 188 | } finally { |
| 189 | if (this.clearing.get(key) === clearing) this.clearing.delete(key); |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | close() { |
| 194 | this.closing ??= this.dispose(); |
| 195 | return this.closing; |
| 196 | } |
| 197 | |
| 198 | private async dispose() { |
| 199 | await Promise.allSettled( |
| 200 | [...this.contexts.values()].map(async (pending) => (await pending).close()), |
| 201 | ); |
| 202 | const browser = await this.browser?.catch(() => undefined); |
| 203 | await browser?.close().catch(constVoid); |
| 204 | this.contexts.clear(); |
| 205 | } |
| 206 | } |