import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import { collectPageSnapshot, SCRIPT_ERROR_PROBE } from "../snapshot/collect"
import { STYLE_PROPS } from "../snapshot/style-props"
import type { PageSnapshot } from "../types"
import { insertAfterProlog } from "./probe-placement"
import { CONTENT_STRESS_SCRIPT, RenderError, type RenderRequest, type RenderTarget, type Renderer } from "./renderer"
const MAX_ELEMENTS = 20_000
const SETTLE_FRAMES = "new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true)))))"
function withProbe(html: string, baseHref?: string): string {
return insertAfterProlog(html, `${baseHref ? `` : ""}`)
}
function isSnapshot(value: unknown): value is PageSnapshot {
return typeof value === "object" && value !== null && Array.isArray(Reflect.get(value, "elements"))
}
/** A renderer backed by Bun.WebView (system WebKit on macOS, installed Chrome/Chromium elsewhere). One view per request. */
export function createWebViewRenderer(options: { readonly backend?: "webkit" | "chrome" } = {}): Renderer {
const scratch = mkdtempSync(join(tmpdir(), "frontend-detect-"))
let counter = 0
const chrome = options.backend === "chrome" || (options.backend === undefined && process.platform !== "darwin")
const writePage = (html: string, baseHref?: string): string => {
const file = join(scratch, `page-${counter++}.html`)
writeFileSync(file, withProbe(html, baseHref))
return pathToFileURL(file).href
}
const open = async (view: Bun.WebView, target: RenderTarget): Promise => {
if (target.kind === "html") return view.navigate(writePage(target.html))
if (chrome) {
await view.navigate("about:blank")
await view.cdp("Page.addScriptToEvaluateOnNewDocument", { source: SCRIPT_ERROR_PROBE })
return view.navigate(target.url)
}
if (target.url.startsWith("file:")) {
const path = fileURLToPath(target.url)
return view.navigate(writePage(readFileSync(path, "utf8"), pathToFileURL(`${dirname(path)}/`).href))
}
return view.navigate(target.url)
}
return {
async render(request: RenderRequest): Promise {
const label = request.target.kind === "url" ? request.target.url : "inline html"
const view = new Bun.WebView({
width: request.viewport.width,
height: request.viewport.height,
...(options.backend ? { backend: options.backend } : {}),
})
try {
await open(view, request.target)
await view.evaluate("document.fonts ? document.fonts.ready.then(() => true) : true")
await view.evaluate(SETTLE_FRAMES)
if (request.contentStress) await view.evaluate(CONTENT_STRESS_SCRIPT)
const snapshot: unknown = await view.evaluate(`(${collectPageSnapshot.toString()})(${JSON.stringify(STYLE_PROPS)}, ${MAX_ELEMENTS})`)
if (!isSnapshot(snapshot)) throw new Error("the page did not return a snapshot")
return snapshot
} catch (error) {
throw new RenderError(label, error)
} finally {
await view.close()
}
},
async close(): Promise {
rmSync(scratch, { recursive: true, force: true })
},
}
}