1import * as Deferred from "effect/Deferred";
2import * as Effect from "effect/Effect";
3import * as Fiber from "effect/Fiber";
4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7import * as Queue from "effect/Queue";
8import * as Schema from "effect/Schema";
9import * as Stream from "effect/Stream";
10import * as ChildProcess from "effect/process/ChildProcess";
11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12
13import { publicProxy } from "./publicProxy.ts";
14
15export class HtmlRenderBrowserError extends Schema.TaggedError<HtmlRenderBrowserError>()(
16 "HtmlRenderBrowserError",
17 {
18 reason: Schema.String,
19 cause: Schema.optional(Schema.Defect()),
20 /** The end of the browser's stderr when it exited, for diagnosing host setup. */
21 output: Schema.optional(Schema.String),
22 },
23) {
24 override get message(): string {
25 return `Headless Chrome could not render the page: ${this.reason}.`;
26 }
27}
28
29type BrowserFailure = HtmlRenderBrowserError;
30
31export interface ConsoleMessage {
32 readonly level: "log" | "info" | "warning" | "error";
33 readonly text: string;
34}
35
36const MAX_CONSOLE_MESSAGES = 20;
37const MAX_CONSOLE_TEXT_CHARS = 500;
38const VIEWPORT_HEIGHT = 800;
39const MAX_CAPTURE_HEIGHT = 4_000;
40const CAPTURE_TIMEOUT = "20 seconds";
41// Pages load from this made-up web origin, never from a file. Chrome refuses
42// local files to every web page, frame, worker, and popup, so a page cannot
43// read files the agent's provider withholds; local images reach it already
44// inlined as data URIs. `.localhost` keeps it a secure context that may still
45// load plain-http resources, and the request never leaves the browser.
46// All of the browser's traffic goes through `publicProxy`, which only reaches
47// public addresses, so a page cannot reach this machine's local network. The
48// main frame also stays on the page and the browser opens no popups, so a
49// capture always shows the page itself.
50const PAGE_ORIGIN = "http://t3-page.localhost";
51const PAGE_URL = `${PAGE_ORIGIN}/page.html`;
52// Stack traces and load errors name the page this way instead of its URL.
53const PAGE_NAME = "page.html";
54// Each measuring load reads its own copy of the page off the pipe.
55const MEASURE_CONCURRENCY = 3;
56
57const textEncoder = new TextEncoder();
58
59/**
60 * A page as the base64 bytes `PAGE_URL` serves, encoded once however often it
61 * loads. Node's encoder, since pages run to 25 MiB.
62 */
63const pageBody = (html: string) =>
64 Buffer.from(Buffer.from(html, "utf8").toString("base64"), "latin1");
65
66const CdpMessage = Schema.fromJsonString(
67 Schema.Struct({
68 id: Schema.optional(Schema.Number),
69 method: Schema.optional(Schema.String),
70 sessionId: Schema.optional(Schema.String),
71 params: Schema.optional(Schema.Unknown),
72 result: Schema.optional(Schema.Unknown),
73 error: Schema.optional(Schema.Struct({ message: Schema.String })),
74 }),
75);
76const decodeCdpMessage = Schema.decodeUnknownOption(CdpMessage);
77const encodeCdpCommand = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
78const RemoteObject = Schema.Struct({
79 type: Schema.String,
80 value: Schema.optional(Schema.Unknown),
81 description: Schema.optional(Schema.String),
82});
83const decodeConsoleApiCalled = Schema.decodeUnknownOption(
84 Schema.Struct({ type: Schema.String, args: Schema.Array(RemoteObject) }),
85);
86const decodeExceptionThrown = Schema.decodeUnknownOption(
87 Schema.Struct({
88 exceptionDetails: Schema.Struct({
89 text: Schema.String,
90 exception: Schema.optional(RemoteObject),
91 }),
92 }),
93);
94const decodeRequestPaused = Schema.decodeUnknownOption(
95 Schema.Struct({
96 requestId: Schema.String,
97 request: Schema.Struct({ url: Schema.String }),
98 frameId: Schema.optional(Schema.String),
99 resourceType: Schema.optional(Schema.String),
100 }),
101);
102const decodeLogEntryAdded = Schema.decodeUnknownOption(
103 Schema.Struct({
104 entry: Schema.Struct({
105 level: Schema.String,
106 text: Schema.String,
107 url: Schema.optional(Schema.String),
108 }),
109 }),
110);
111
112const remoteObjectText = (value: typeof RemoteObject.Type) =>
113 typeof value.value === "string"
114 ? value.value
115 : (value.description ?? (value.value === undefined ? value.type : String(value.value)));
116
117const CONSOLE_LEVELS: Readonly<Record<string, ConsoleMessage["level"]>> = {
118 log: "log",
119 debug: "log",
120 dir: "log",
121 dirxml: "log",
122 table: "log",
123 trace: "log",
124 info: "info",
125 warning: "warning",
126 error: "error",
127 assert: "error",
128};
129
130/** Everything the page logs, uncaught exceptions, and the browser's own load errors. */
131const consoleMessageFromEvent = (method: string, params: unknown): ConsoleMessage | undefined => {
132 if (method === "Runtime.consoleAPICalled") {
133 const event = Option.getOrUndefined(decodeConsoleApiCalled(params));
134 const level = event === undefined ? undefined : CONSOLE_LEVELS[event.type];
135 return event && level ? { level, text: event.args.map(remoteObjectText).join(" ") } : undefined;
136 }
137 if (method === "Runtime.exceptionThrown") {
138 const details = Option.getOrUndefined(decodeExceptionThrown(params))?.exceptionDetails;
139 return details
140 ? { level: "error", text: details.exception?.description ?? details.text }
141 : undefined;
142 }
143 if (method === "Log.entryAdded") {
144 const entry = Option.getOrUndefined(decodeLogEntryAdded(params))?.entry;
145 return entry && (entry.level === "error" || entry.level === "warning")
146 ? { level: entry.level, text: entry.url ? `${entry.text} ${entry.url}` : entry.text }
147 : undefined;
148 }
149 return undefined;
150};
151
152// Resolves after web fonts load and two frames paint, so late layout lands in the capture.
153const SETTLE_EXPRESSION =
154 "document.fonts.ready.then(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true)))))";
155// The root's scroll height never drops below the viewport, so a short page
156// reports its own box height instead.
157const MEASURE_EXPRESSION =
158 "(() => { const root = document.documentElement; return root.scrollHeight > root.clientHeight ? root.scrollHeight : root.getBoundingClientRect().height; })()";
159
160const Ignored = Schema.Unknown;
161const Navigation = Schema.Struct({ errorText: Schema.optional(Schema.String) });
162const Measured = Schema.Struct({ result: Schema.Struct({ value: Schema.Finite }) });
163
164interface PageEvents {
165 /** The page's main frame, which only ever shows `PAGE_URL`. */
166 readonly mainFrameId: string;
167 /** The page served for `PAGE_URL`, from `pageBody`. */
168 body: Uint8Array | undefined;
169 loaded: Deferred.Deferred<void, BrowserFailure> | undefined;
170 readonly consoleMessages: Array<ConsoleMessage>;
171 omittedConsoleMessages: number;
172}
173
174/**
175 * Starts the headless shell for the life of the scope, speaking CDP over
176 * `--remote-debugging-pipe` (fd 3 in, fd 4 out, NUL-delimited JSON). Scope
177 * close kills the process group. Each page is its own target, so pages load
178 * in parallel.
179 */
180const launchBrowser = Effect.fnUntraced(function* (input: {
181 readonly executable: string;
182 readonly noSandbox: boolean;
183 readonly profileDirectory: string;
184}) {
185 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
186 const proxyPort = yield* publicProxy.pipe(
187 Effect.mapError(
188 (cause) => new HtmlRenderBrowserError({ reason: "the preview proxy could not start", cause }),
189 ),
190 );
191 const outgoing = yield* Queue.unbounded<Uint8Array>();
192 const child = yield* spawner
193 .spawn(
194 ChildProcess.make(
195 input.executable,
196 [
197 "--headless=new",
198 "--remote-debugging-pipe",
199 "--no-first-run",
200 "--no-default-browser-check",
201 "--disable-gpu",
202 "--hide-scrollbars",
203 "--mute-audio",
204 "--block-new-web-contents",
205 `--proxy-server=socks5://127.0.0.1:${proxyPort}`,
206 // Loopback would otherwise skip the proxy.
207 "--proxy-bypass-list=<-loopback>",
208 // WebRTC would otherwise send UDP, which no proxy carries.
209 "--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
210 ...(input.noSandbox ? ["--no-sandbox"] : []),
211 `--user-data-dir=${input.profileDirectory}`,
212 "about:blank",
213 ],
214 {
215 stdin: "ignore",
216 stdout: "ignore",
217 stderr: "pipe",
218 forceKillAfter: "2 seconds",
219 additionalFds: {
220 fd3: { type: "input", stream: Stream.fromQueue(outgoing) },
221 fd4: { type: "output" },
222 },
223 },
224 ),
225 )
226 .pipe(
227 Effect.mapError(
228 (cause) =>
229 new HtmlRenderBrowserError({ reason: "the browser could not be started", cause }),
230 ),
231 );
232
233 let stderrTail = "";
234 const stderrReader = yield* child.stderr.pipe(
235 Stream.decodeText(),
236 Stream.runForEach((text) =>
237 Effect.sync(() => {
238 stderrTail = (stderrTail + text).slice(-2_048);
239 }),
240 ),
241 Effect.ignore,
242 Effect.forkScoped,
243 );
244
245 const pending = new Map<
246 number,
247 { readonly method: string; readonly reply: Deferred.Deferred<unknown, BrowserFailure> }
248 >();
249 const pages = new Map<string, PageEvents>();
250 let disconnected: BrowserFailure | undefined;
251 let nextId = 0;
252
253 // A command whose reply nobody awaits; `receive` drops replies without a waiter.
254 const post = (method: string, params: Record<string, unknown>, sessionId: string) =>
255 Queue.offer(
256 outgoing,
257 new TextEncoder().encode(
258 `${encodeCdpCommand({ id: ++nextId, method, params, sessionId })}\0`,
259 ),
260 ).pipe(Effect.asVoid);
261
262 // Serves the page. Its body is shared bytes between two small JSON halves,
263 // queued together, so loading a page of up to 25 MiB at several widths never
264 // copies it on this side.
265 const fulfillPage = (sessionId: string, requestId: string, body: Uint8Array) =>
266 Queue.offerAll(outgoing, [
267 textEncoder.encode(
268 `{"id":${++nextId},"sessionId":${JSON.stringify(sessionId)},"method":"Fetch.fulfillRequest","params":{"requestId":${JSON.stringify(requestId)},"responseCode":200,"responseHeaders":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"body":"`,
269 ),
270 body,
271 textEncoder.encode('"}}\0'),
272 ]).pipe(Effect.asVoid);
273
274 const receive = (raw: string) => {
275 const message = Option.getOrUndefined(decodeCdpMessage(raw));
276 if (message?.id !== undefined) {
277 const waiter = pending.get(message.id);
278 pending.delete(message.id);
279 if (!waiter) return Effect.void;
280 return message.error
281 ? Deferred.fail(
282 waiter.reply,
283 new HtmlRenderBrowserError({
284 reason: `${waiter.method} failed`,
285 cause: message.error.message,
286 }),
287 )
288 : Deferred.succeed(waiter.reply, message.result);
289 }
290 const sessionId = message?.sessionId;
291 const page = sessionId === undefined ? undefined : pages.get(sessionId);
292 if (sessionId === undefined || !page || !message?.method) return Effect.void;
293 if (message.method === "Page.loadEventFired") {
294 return page.loaded ? Deferred.succeed(page.loaded, undefined) : Effect.void;
295 }
296 if (message.method === "Fetch.requestPaused") {
297 const paused = Option.getOrUndefined(decodeRequestPaused(message.params));
298 if (!paused) return Effect.void;
299 const body = page.body;
300 const url = paused.request.url.split("#", 1)[0]!;
301 if (url === PAGE_URL && body !== undefined) {
302 return fulfillPage(sessionId, paused.requestId, body);
303 }
304 // A frame inside the page may show another site; Local Network Access
305 // still covers it. The main frame and T3's own origin serve nothing else.
306 const otherSiteFrame =
307 paused.resourceType === "Document" &&
308 paused.frameId !== page.mainFrameId &&
309 !url.startsWith(`${PAGE_ORIGIN}/`);
310 return otherSiteFrame
311 ? post("Fetch.continueRequest", { requestId: paused.requestId }, sessionId)
312 : post(
313 "Fetch.failRequest",
314 { requestId: paused.requestId, errorReason: "AccessDenied" },
315 sessionId,
316 );
317 }
318 const consoleMessage = consoleMessageFromEvent(message.method, message.params);
319 if (!consoleMessage) return Effect.void;
320 if (page.consoleMessages.length >= MAX_CONSOLE_MESSAGES) {
321 page.omittedConsoleMessages += 1;
322 return Effect.void;
323 }
324 const text = consoleMessage.text.replaceAll(PAGE_URL, PAGE_NAME);
325 page.consoleMessages.push({
326 level: consoleMessage.level,
327 text:
328 text.length > MAX_CONSOLE_TEXT_CHARS ? `${text.slice(0, MAX_CONSOLE_TEXT_CHARS)}…` : text,
329 });
330 return Effect.void;
331 };
332
333 // The pipe closes when the browser exits. A startup abort, such as a missing
334 // sandbox, says why on stderr, which may still be draining, so give it a moment.
335 const disconnect = Effect.gen(function* () {
336 yield* Fiber.await(stderrReader).pipe(Effect.timeout("1 second"), Effect.ignore);
337 const error = new HtmlRenderBrowserError({
338 reason: "the browser exited unexpectedly",
339 output: stderrTail,
340 });
341 disconnected = error;
342 const waiters = [...pending.values()];
343 pending.clear();
344 yield* Effect.forEach(waiters, ({ reply }) => Deferred.fail(reply, error), { discard: true });
345 yield* Effect.forEach(
346 pages.values(),
347 (page) => (page.loaded ? Deferred.fail(page.loaded, error) : Effect.void),
348 { discard: true },
349 );
350 });
351
352 // Decoded text chunks may split a message anywhere, and a screenshot reply spans many.
353 let partial: Array<string> = [];
354 yield* child.getOutputFd(4).pipe(
355 Stream.decodeText(),
356 Stream.runForEach((text) => {
357 const complete: Array<string> = [];
358 let start = 0;
359 for (let end = text.indexOf("\0"); end !== -1; end = text.indexOf("\0", start)) {
360 partial.push(text.slice(start, end));
361 complete.push(partial.join(""));
362 partial = [];
363 start = end + 1;
364 }
365 if (start < text.length) partial.push(text.slice(start));
366 return Effect.forEach(complete, receive, { discard: true });
367 }),
368 Effect.ignore,
369 // Interruption means the scope is closing on purpose; only an exit disconnects.
370 Effect.andThen(disconnect),
371 Effect.forkScoped,
372 );
373
374 const send = <A>(
375 method: string,
376 params: Record<string, unknown>,
377 result: Schema.Decoder<A>,
378 sessionId?: string,
379 ) =>
380 Effect.gen(function* () {
381 if (disconnected) return yield* disconnected;
382 const id = ++nextId;
383 const reply = yield* Deferred.make<unknown, BrowserFailure>();
384 pending.set(id, { method, reply });
385 const message = encodeCdpCommand({ id, method, params, ...(sessionId ? { sessionId } : {}) });
386 yield* Queue.offer(outgoing, new TextEncoder().encode(`${message}\0`));
387 return yield* Deferred.await(reply).pipe(
388 Effect.flatMap(Schema.decodeUnknownEffect(result)),
389 Effect.mapError((error) =>
390 error._tag === "HtmlRenderBrowserError"
391 ? error
392 : new HtmlRenderBrowserError({
393 reason: `${method} returned an unexpected result`,
394 cause: error,
395 }),
396 ),
397 );
398 });
399
400 /** A fresh page at `width`; each `load` navigates it and measures the settled layout. */
401 const openPage = Effect.fnUntraced(function* (width: number) {
402 const { targetId } = yield* send(
403 "Target.createTarget",
404 { url: "about:blank" },
405 Schema.Struct({ targetId: Schema.String }),
406 );
407 const { sessionId } = yield* send(
408 "Target.attachToTarget",
409 { targetId, flatten: true },
410 Schema.Struct({ sessionId: Schema.String }),
411 );
412 const events: PageEvents = {
413 // A page target's id is its main frame's id.
414 mainFrameId: targetId,
415 body: undefined,
416 loaded: undefined,
417 consoleMessages: [],
418 omittedConsoleMessages: 0,
419 };
420 pages.set(sessionId, events);
421 yield* send("Page.enable", {}, Ignored, sessionId);
422 yield* send("Runtime.enable", {}, Ignored, sessionId);
423 yield* send("Log.enable", {}, Ignored, sessionId);
424 // Previews have no use for WebRTC, whose ICE servers can make the browser
425 // resolve names outside the proxy. Runs before page scripts in every frame.
426 yield* send(
427 "Page.addScriptToEvaluateOnNewDocument",
428 {
429 source: "delete window.RTCPeerConnection; delete window.webkitRTCPeerConnection;",
430 runImmediately: true,
431 },
432 Ignored,
433 sessionId,
434 );
435 // Pauses T3's own origin, to serve the page, and every document, to keep
436 // the main frame on it.
437 yield* send(
438 "Fetch.enable",
439 {
440 patterns: [
441 { urlPattern: `${PAGE_ORIGIN}/*` },
442 { urlPattern: "*", resourceType: "Document" },
443 ],
444 },
445 Ignored,
446 sessionId,
447 );
448 yield* send(
449 "Emulation.setDeviceMetricsOverride",
450 { width, height: VIEWPORT_HEIGHT, deviceScaleFactor: 1, mobile: false },
451 Ignored,
452 sessionId,
453 );
454
455 /** Loads a page from `pageBody`, so callers loading it often encode it once. */
456 const load = Effect.fnUntraced(function* (body: Uint8Array, urlFragment: string) {
457 const loaded = yield* Deferred.make<void, BrowserFailure>();
458 events.body = body;
459 events.loaded = loaded;
460 const navigation = yield* send(
461 "Page.navigate",
462 { url: `${PAGE_URL}${urlFragment}` },
463 Navigation,
464 sessionId,
465 );
466 if (navigation.errorText) {
467 return yield* new HtmlRenderBrowserError({ reason: "the page could not be opened" });
468 }
469 yield* Deferred.await(loaded);
470 yield* send(
471 "Runtime.evaluate",
472 { expression: SETTLE_EXPRESSION, awaitPromise: true },
473 Ignored,
474 sessionId,
475 );
476 const measured = yield* send(
477 "Runtime.evaluate",
478 { expression: MEASURE_EXPRESSION, returnByValue: true },
479 Measured,
480 sessionId,
481 );
482 return Math.max(0, Math.ceil(measured.result.value));
483 });
484
485 const screenshot = (height: number) =>
486 send(
487 "Page.captureScreenshot",
488 {
489 format: "png",
490 captureBeyondViewport: true,
491 clip: { x: 0, y: 0, width, height, scale: 1 },
492 },
493 Schema.Struct({ data: Schema.String }),
494 sessionId,
495 ).pipe(Effect.map(({ data }) => data));
496
497 const consoleMessages = (): ReadonlyArray<ConsoleMessage> =>
498 events.omittedConsoleMessages === 0
499 ? [...events.consoleMessages]
500 : [
501 ...events.consoleMessages,
502 {
503 level: "warning",
504 text: `${events.omittedConsoleMessages} more console messages were omitted.`,
505 },
506 ];
507
508 // Frees the page in the browser once it is no longer needed.
509 const close = send("Target.closeTarget", { targetId }, Ignored).pipe(
510 Effect.ensuring(Effect.sync(() => pages.delete(sessionId))),
511 Effect.ignore,
512 );
513
514 return { load, screenshot, consoleMessages, close };
515 });
516
517 return { openPage };
518});
519
520/** A temporary directory for the browser profile, removed with the scope. */
521const scratchDirectory = Effect.gen(function* () {
522 const fileSystem = yield* FileSystem.FileSystem;
523 return yield* Effect.acquireRelease(
524 fileSystem.makeTempDirectory({ prefix: "t3-html-preview-" }),
525 (directory) => fileSystem.remove(directory, { recursive: true }).pipe(Effect.ignore),
526 ).pipe(
527 Effect.mapError(
528 (cause) =>
529 new HtmlRenderBrowserError({ reason: "a temporary directory could not be created", cause }),
530 ),
531 );
532});
533
534/** Loads `html` at `width` and returns a PNG of the top of the page. */
535export const captureHtmlScreenshot = Effect.fn("headlessChrome.captureHtmlScreenshot")(
536 function* (input: {
537 readonly executable: string;
538 readonly noSandbox: boolean;
539 readonly html: string;
540 readonly width: number;
541 readonly urlFragment: string;
542 }) {
543 const path = yield* Path.Path;
544 const directory = yield* scratchDirectory;
545 const browser = yield* launchBrowser({
546 executable: input.executable,
547 noSandbox: input.noSandbox,
548 profileDirectory: path.join(directory, "profile"),
549 });
550 const page = yield* browser.openPage(input.width);
551 const contentHeight = yield* page.load(pageBody(input.html), input.urlFragment);
552 const capturedHeight = Math.max(1, Math.min(contentHeight, MAX_CAPTURE_HEIGHT));
553 const png = yield* page.screenshot(capturedHeight);
554 return { png, contentHeight, capturedHeight, consoleMessages: page.consoleMessages() };
555 },
556 Effect.scoped,
557 Effect.timeoutOrElse({
558 duration: CAPTURE_TIMEOUT,
559 orElse: () =>
560 Effect.fail(
561 new HtmlRenderBrowserError({
562 reason: `the page did not finish loading within ${CAPTURE_TIMEOUT}`,
563 }),
564 ),
565 }),
566);
567
568/**
569 * Content heights of the page at each width, each from a fresh load, since
570 * pages often lay themselves out from the width once at load. One browser,
571 * a few widths at a time.
572 */
573export const measureHtmlHeights = Effect.fn("headlessChrome.measureHtmlHeights")(function* (input: {
574 readonly executable: string;
575 readonly noSandbox: boolean;
576 readonly html: string;
577 readonly widths: ReadonlyArray<number>;
578 readonly urlFragment: string;
579}) {
580 const path = yield* Path.Path;
581 const directory = yield* scratchDirectory;
582 const browser = yield* launchBrowser({
583 executable: input.executable,
584 noSandbox: input.noSandbox,
585 profileDirectory: path.join(directory, "profile"),
586 });
587 const body = pageBody(input.html);
588 return yield* Effect.forEach(
589 input.widths,
590 (width) =>
591 browser.openPage(width).pipe(
592 Effect.flatMap((page) =>
593 page.load(body, input.urlFragment).pipe(Effect.ensuring(page.close)),
594 ),
595 Effect.map((height) => [width, height] as const),
596 ),
597 { concurrency: MEASURE_CONCURRENCY },
598 );
599}, Effect.scoped);