Open History to see the last commit
1// @effect-diagnostics nodeBuiltinImport:off globalTimers:off -- Owns Playwright resources and their idle timer 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 * as NodeOS from "node:os";
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 readonly executable: () => Promise<string>;
20 /**
21 * A path that starts `executable`, such as one that moves Chrome out of the server's cgroup.
22 * Released after the whole launch call, since Playwright retries the same path after a glibc race.
23 */
24 readonly launcher?: (
25 executable: string,
26 ) => Promise<{ readonly path: string; readonly release: () => Promise<void> } | undefined>;
27 readonly diagnose?: (executable: string, cause: unknown) => Promise<unknown>;
28 readonly env?: NodeJS.ProcessEnv;
29 /** Closes the shared browser once it has held no context for this long. */
30 readonly idleBrowserMs?: number;
31 readonly onContextClose?: (context: BrowserContext) => void;
32}
33
34const UA_PLATFORMS: Partial<Record<NodeJS.Platform, string>> = {
35 darwin: "macOS",
36 linux: "Linux",
37 win32: "Windows",
38};
39
40/** Change user agent and Sec-CH-UA together; sites block mismatches. */
41const chromeIdentity = (
42 headlessUserAgent: string,
43 host: { readonly platform: NodeJS.Platform; readonly arch: string; readonly release: string },
44) => {
45 const version = /HeadlessChrome\/((\d+)[\d.]*)/.exec(headlessUserAgent);
46 const fullVersion = version?.[1];
47 const major = version?.[2];
48 if (!version || !fullVersion || !major) return null;
49 const brands = (names: (version: string) => string) => [
50 { brand: "Chromium", version: names(major) },
51 { brand: "Google Chrome", version: names(major) },
52 { brand: "Not A(Brand", version: names("99") },
53 ];
54 return {
55 userAgent: headlessUserAgent.replace(version[0], `Chrome/${major}.0.0.0`),
56 userAgentMetadata: {
57 brands: brands((value) => value),
58 fullVersionList: brands((value) => (value === major ? fullVersion : `${value}.0.0.0`)),
59 platform: UA_PLATFORMS[host.platform] ?? "Unknown",
60 platformVersion: host.platform === "linux" ? host.release : "",
61 architecture: host.arch.startsWith("arm") ? "arm" : "x86",
62 bitness: host.arch.endsWith("64") ? "64" : "32",
63 model: "",
64 mobile: false,
65 wow64: false,
66 },
67 };
68};
69
70export const presentAsChrome = async (
71 cdp: CDPSession,
72 host: { readonly platform: NodeJS.Platform; readonly arch: string },
73) => {
74 const { userAgent } = await cdp.send("Browser.getVersion");
75 const identity = chromeIdentity(userAgent, { ...host, release: NodeOS.release() });
76 if (identity) await cdp.send("Emulation.setUserAgentOverride", identity);
77};
78
79export class ServerBrowserContexts {
80 private readonly options: Options;
81 private readonly contexts = new Map<string, Promise<BrowserContext>>();
82 private readonly clearing = new Map<string, Promise<void>>();
83 private browser: Promise<Browser> | undefined;
84 private closing: Promise<void> | undefined;
85 private idleTimer: ReturnType<typeof setTimeout> | undefined;
86 private pendingSharedContexts = 0;
87
88 constructor(options: Options) {
89 this.options = options;
90 }
91
92 private async launchOptions() {
93 const executable = await this.options.executable();
94 const env = this.options.env ?? process.env;
95 return {
96 executable,
97 env,
98 args: [
99 "--disable-gpu",
100 "--force-device-scale-factor=2",
101 "--disable-blink-features=AutomationControlled",
102 ],
103 ignoreDefaultArgs: ["--enable-automation"],
104 headless: true,
105 // Only an explicit operator opt-out disables sandboxing. Launch errors never do.
106 chromiumSandbox: !sandboxDisabled(env),
107 };
108 }
109
110 private async launch<A>(executable: string, start: (executablePath: string) => Promise<A>) {
111 let release: (() => Promise<void>) | undefined;
112 try {
113 const launcher = await this.options.launcher?.(executable);
114 release = launcher?.release;
115 return await start(launcher?.path ?? executable);
116 } catch (cause) {
117 throw (await this.options.diagnose?.(executable, cause)) ?? cause;
118 } finally {
119 await release?.().catch(constVoid);
120 }
121 }
122
123 private sharedBrowser() {
124 if (!this.browser) {
125 const launched = this.launchOptions().then(async ({ executable, ...options }) => {
126 const { chromium } = loadPlaywright();
127 const browser = await this.launch(executable, (executablePath) =>
128 chromium.launch({ ...options, executablePath }),
129 );
130 browser.on("disconnected", () => {
131 if (this.browser === launched) this.browser = undefined;
132 });
133 return browser;
134 });
135 this.browser = launched;
136 void launched.catch(() => {
137 if (this.browser === launched) this.browser = undefined;
138 });
139 }
140 return this.browser;
141 }
142
143 private async sharedContext(options?: Parameters<Browser["newContext"]>[0]) {
144 clearTimeout(this.idleTimer);
145 this.pendingSharedContexts++;
146 const launched = this.sharedBrowser();
147 try {
148 const context = await (await launched).newContext(options);
149 context.on("close", () => this.retireWhenIdle(launched));
150 return context;
151 } finally {
152 this.pendingSharedContexts--;
153 this.retireWhenIdle(launched);
154 }
155 }
156
157 private retireWhenIdle(launched: Promise<Browser>) {
158 const delay = this.options.idleBrowserMs;
159 if (delay === undefined || this.closing || this.pendingSharedContexts > 0) return;
160 clearTimeout(this.idleTimer);
161 this.idleTimer = setTimeout(() => void this.retire(launched), delay);
162 this.idleTimer.unref();
163 }
164
165 private async retire(launched: Promise<Browser>) {
166 const browser = await launched.catch(() => undefined);
167 if (!browser || this.browser !== launched || this.pendingSharedContexts > 0) return;
168 if (browser.contexts().length > 0) return;
169 // Cleared first, so a request during the close launches a fresh browser.
170 this.browser = undefined;
171 await browser.close().catch(constVoid);
172 }
173
174 contextFor(profileId: string, isolationKey?: string): Promise<BrowserContext> {
175 if (this.closing) return Promise.reject(new Error("The preview browser is closed."));
176 const key = JSON.stringify([profileId, isolationKey ?? null]);
177 const cached = this.contexts.get(key);
178 if (cached) return cached;
179 const cleared = this.clearing.get(key)?.catch(constVoid) ?? Promise.resolve();
180 const pending = cleared
181 .then(() => this.createContext(profileId, isolationKey))
182 .then(async (context) => {
183 context.on("close", () => {
184 if (this.contexts.get(key) === pending) this.contexts.delete(key);
185 this.options.onContextClose?.(context);
186 });
187 for (const page of context.pages()) await page.close().catch(constVoid);
188 if (this.closing) {
189 await context.close().catch(constVoid);
190 throw new Error("The preview browser is closed.");
191 }
192 return context;
193 });
194 this.contexts.set(key, pending);
195 void pending.catch(() => {
196 if (this.contexts.get(key) === pending) this.contexts.delete(key);
197 });
198 return pending;
199 }
200
201 private async createContext(profileId: string, isolationKey?: string) {
202 const contextOptions = { viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 };
203 if (isolationKey !== undefined || profileId === INCOGNITO_BROWSER_PROFILE_ID) {
204 return this.sharedContext(contextOptions);
205 }
206 const directory = this.profileDirectory(profileId);
207 await NodeFSP.mkdir(directory, { recursive: true });
208 const { executable, ...options } = await this.launchOptions();
209 const { chromium } = loadPlaywright();
210 return this.launch(executable, (executablePath) =>
211 chromium.launchPersistentContext(directory, {
212 ...options,
213 ...contextOptions,
214 executablePath,
215 }),
216 );
217 }
218
219 private profileDirectory(profileId: string) {
220 const encoded = encodeURIComponent(profileId);
221 return NodePath.join(
222 this.options.profilesDir,
223 profileId === "." || profileId === ".." ? encoded.replaceAll(".", "%2E") : encoded,
224 );
225 }
226
227 async scratchPage() {
228 if (this.closing) throw new Error("The preview browser is closed.");
229 const context = await this.sharedContext();
230 try {
231 const page = await context.newPage();
232 page.once("close", () => void context.close().catch(constVoid));
233 return page;
234 } catch (cause) {
235 await context.close().catch(constVoid);
236 throw cause;
237 }
238 }
239
240 async connectDesktopPage(endpoint: string) {
241 const { chromium } = loadPlaywright();
242 const browser = await chromium.connectOverCDP(endpoint, { timeout: 15_000 });
243 const page = browser.contexts().flatMap((context) => context.pages())[0];
244 if (!page) {
245 await browser.close().catch(constVoid);
246 throw new Error("The desktop tab has no page.");
247 }
248 return { browser, page };
249 }
250
251 async clearProfile(profileId: string) {
252 if (profileId === INCOGNITO_BROWSER_PROFILE_ID) return;
253 const key = JSON.stringify([profileId, null]);
254 const existing = this.clearing.get(key);
255 if (existing) return existing;
256 const clearing = (async () => {
257 const pending = this.contexts.get(key);
258 if (pending) {
259 this.contexts.delete(key);
260 const context = await pending.catch(() => undefined);
261 await context?.close();
262 }
263 await NodeFSP.rm(this.profileDirectory(profileId), { recursive: true, force: true });
264 })();
265 this.clearing.set(key, clearing);
266 try {
267 await clearing;
268 } finally {
269 if (this.clearing.get(key) === clearing) this.clearing.delete(key);
270 }
271 }
272
273 close() {
274 this.closing ??= this.dispose();
275 return this.closing;
276 }
277
278 private async dispose() {
279 clearTimeout(this.idleTimer);
280 await Promise.allSettled(
281 [...this.contexts.values()].map(async (pending) => (await pending).close()),
282 );
283 const browser = await this.browser?.catch(() => undefined);
284 await browser?.close().catch(constVoid);
285 this.contexts.clear();
286 }
287}