1// @effect-diagnostics nodeBuiltinImport:off - Owns Playwright resources outside the Effect runtime.
2import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";
3import { constVoid } from "effect/Function";
4import * as NodeFSP from "node:fs/promises";
5import * as NodeModule from "node:module";
6import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity";
7import * as NodePath from "node:path";
8import type { Browser, BrowserContext, CDPSession } from "playwright-core";
9
10import { sandboxDisabled } from "./PreviewBrowserHost.ts";
11
12// Playwright needs its files on disk. createRequire also resolves it from a Node SEA executable.
13const requirePlaywright = NodeModule.createRequire(import.meta.url);
14const loadPlaywright = () =>
15 requirePlaywright("playwright-core") as typeof import("playwright-core");
16
17interface 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 */
31export 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. */
41export 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}