| 1 | import * as Deferred from "effect/Deferred"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Fiber from "effect/Fiber"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Option from "effect/Option"; |
| 6 | import * as Path from "effect/Path"; |
| 7 | import * as Queue from "effect/Queue"; |
| 8 | import * as Schema from "effect/Schema"; |
| 9 | import * as Stream from "effect/Stream"; |
| 10 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 11 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 12 | |
| 13 | import { publicProxy } from "./publicProxy.ts"; |
| 14 | |
| 15 | export 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 | |
| 29 | type BrowserFailure = HtmlRenderBrowserError; |
| 30 | |
| 31 | export interface ConsoleMessage { |
| 32 | readonly level: "log" | "info" | "warning" | "error"; |
| 33 | readonly text: string; |
| 34 | } |
| 35 | |
| 36 | const MAX_CONSOLE_MESSAGES = 20; |
| 37 | const MAX_CONSOLE_TEXT_CHARS = 500; |
| 38 | const VIEWPORT_HEIGHT = 800; |
| 39 | const MAX_CAPTURE_HEIGHT = 4_000; |
| 40 | const 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. |
| 50 | const PAGE_ORIGIN = "http://t3-page.localhost"; |
| 51 | const PAGE_URL = `${PAGE_ORIGIN}/page.html`; |
| 52 | // Stack traces and load errors name the page this way instead of its URL. |
| 53 | const PAGE_NAME = "page.html"; |
| 54 | // Each measuring load reads its own copy of the page off the pipe. |
| 55 | const MEASURE_CONCURRENCY = 3; |
| 56 | |
| 57 | const 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 | */ |
| 63 | const pageBody = (html: string) => |
| 64 | Buffer.from(Buffer.from(html, "utf8").toString("base64"), "latin1"); |
| 65 | |
| 66 | const 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 | ); |
| 76 | const decodeCdpMessage = Schema.decodeUnknownOption(CdpMessage); |
| 77 | const encodeCdpCommand = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 78 | const RemoteObject = Schema.Struct({ |
| 79 | type: Schema.String, |
| 80 | value: Schema.optional(Schema.Unknown), |
| 81 | description: Schema.optional(Schema.String), |
| 82 | }); |
| 83 | const decodeConsoleApiCalled = Schema.decodeUnknownOption( |
| 84 | Schema.Struct({ type: Schema.String, args: Schema.Array(RemoteObject) }), |
| 85 | ); |
| 86 | const decodeExceptionThrown = Schema.decodeUnknownOption( |
| 87 | Schema.Struct({ |
| 88 | exceptionDetails: Schema.Struct({ |
| 89 | text: Schema.String, |
| 90 | exception: Schema.optional(RemoteObject), |
| 91 | }), |
| 92 | }), |
| 93 | ); |
| 94 | const 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 | ); |
| 102 | const 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 | |
| 112 | const 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 | |
| 117 | const 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. */ |
| 131 | const 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. |
| 153 | const 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. |
| 157 | const MEASURE_EXPRESSION = |
| 158 | "(() => { const root = document.documentElement; return root.scrollHeight > root.clientHeight ? root.scrollHeight : root.getBoundingClientRect().height; })()"; |
| 159 | |
| 160 | const Ignored = Schema.Unknown; |
| 161 | const Navigation = Schema.Struct({ errorText: Schema.optional(Schema.String) }); |
| 162 | const Measured = Schema.Struct({ result: Schema.Struct({ value: Schema.Finite }) }); |
| 163 | |
| 164 | interface 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 | */ |
| 180 | const 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. */ |
| 521 | const 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. */ |
| 535 | export 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 | */ |
| 573 | export 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); |