192 lines
6.4 KiB
JavaScript
192 lines
6.4 KiB
JavaScript
/**
|
|||
|
|
* Shared Chrome DevTools Protocol helpers for OpenChamber performance tooling.
|
||
|
|
*
|
||
|
|
* `scripts/profile-browser.mjs` and `scripts/profile-idle.mjs` both drive Chrome
|
||
|
|
* over CDP. Launching, target discovery, and the minimal protocol client live
|
||
|
|
* here so both entry points stay thin and behave identically.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { spawn } from "node:child_process"
|
||
|
|
import { createServer } from "node:net"
|
||
|
|
import { existsSync } from "node:fs"
|
||
|
|
import { platform } from "node:os"
|
||
|
|
import { join, resolve } from "node:path"
|
||
|
|
import process from "node:process"
|
||
|
|
|
||
|
|
export const wait = (milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds))
|
||
|
|
|
||
|
|
const chromeCandidates = () => {
|
||
|
|
if (platform() === "darwin") {
|
||
|
|
return [
|
||
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||
|
|
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||
|
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||
|
|
]
|
||
|
|
}
|
||
|
|
if (platform() === "win32") {
|
||
|
|
return [
|
||
|
|
join(process.env.PROGRAMFILES ?? "", "Google/Chrome/Application/chrome.exe"),
|
||
|
|
join(process.env["PROGRAMFILES(X86)"] ?? "", "Google/Chrome/Application/chrome.exe"),
|
||
|
|
join(process.env.LOCALAPPDATA ?? "", "Google/Chrome/Application/chrome.exe"),
|
||
|
|
]
|
||
|
|
}
|
||
|
|
return ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"]
|
||
|
|
}
|
||
|
|
|
||
|
|
export const resolveChrome = (explicit) => {
|
||
|
|
if (explicit) {
|
||
|
|
const candidate = resolve(explicit)
|
||
|
|
if (!existsSync(candidate)) throw new Error(`Chrome executable not found: ${candidate}`)
|
||
|
|
return candidate
|
||
|
|
}
|
||
|
|
const candidate = chromeCandidates().find((path) => path && existsSync(path))
|
||
|
|
if (!candidate) throw new Error("Chrome/Chromium was not found. Pass its path with --chrome.")
|
||
|
|
return candidate
|
||
|
|
}
|
||
|
|
|
||
|
|
export const reservePort = () => new Promise((resolvePort, reject) => {
|
||
|
|
const server = createServer()
|
||
|
|
server.unref()
|
||
|
|
server.on("error", reject)
|
||
|
|
server.listen(0, "127.0.0.1", () => {
|
||
|
|
const address = server.address()
|
||
|
|
if (!address || typeof address === "string") {
|
||
|
|
server.close()
|
||
|
|
reject(new Error("Could not reserve a Chrome debugging port"))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
const port = address.port
|
||
|
|
server.close(() => resolvePort(port))
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
const waitForJson = async (url, timeoutMs = 15_000) => {
|
||
|
|
const deadline = Date.now() + timeoutMs
|
||
|
|
let lastError
|
||
|
|
while (Date.now() < deadline) {
|
||
|
|
try {
|
||
|
|
const response = await fetch(url)
|
||
|
|
if (response.ok) return await response.json()
|
||
|
|
} catch (error) {
|
||
|
|
lastError = error
|
||
|
|
}
|
||
|
|
await wait(100)
|
||
|
|
}
|
||
|
|
throw new Error(`Chrome debugging endpoint did not start: ${lastError?.message ?? url}`)
|
||
|
|
}
|
||
|
|
|
||
|
|
export const createPageTarget = async (port) => {
|
||
|
|
const baseUrl = `http://127.0.0.1:${port}`
|
||
|
|
await waitForJson(`${baseUrl}/json/version`)
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await fetch(`${baseUrl}/json/new?${encodeURIComponent("about:blank")}`, { method: "PUT" })
|
||
|
|
if (response.ok) {
|
||
|
|
const target = await response.json()
|
||
|
|
if (target?.type === "page" && target.webSocketDebuggerUrl) return target
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// Some Chromium variants do not expose /json/new; use their startup page.
|
||
|
|
}
|
||
|
|
|
||
|
|
const targets = await waitForJson(`${baseUrl}/json`)
|
||
|
|
const target = targets.find((entry) => entry.type === "page" && entry.webSocketDebuggerUrl)
|
||
|
|
if (!target) throw new Error("Chrome did not expose or create a page target")
|
||
|
|
return target
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Chrome throttles timers and stops producing frames for windows it considers
|
||
|
|
* backgrounded or occluded. A profiling run must never silently measure a
|
||
|
|
* throttled renderer, so occlusion and background throttling are disabled for
|
||
|
|
* every launch: without these the idle report shows zero layouts per second
|
||
|
|
* regardless of how much work the page actually schedules.
|
||
|
|
*/
|
||
|
|
const ANTI_THROTTLING_ARGS = [
|
||
|
|
"--disable-background-timer-throttling",
|
||
|
|
"--disable-backgrounding-occluded-windows",
|
||
|
|
"--disable-renderer-backgrounding",
|
||
|
|
"--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling",
|
||
|
|
]
|
||
|
|
|
||
|
|
export const launchChrome = ({ chrome, profileDir, port, headless, extraArgs = [] }) => {
|
||
|
|
const args = [
|
||
|
|
`--remote-debugging-port=${port}`,
|
||
|
|
`--user-data-dir=${profileDir}`,
|
||
|
|
"--no-first-run",
|
||
|
|
"--no-default-browser-check",
|
||
|
|
"--disable-background-networking",
|
||
|
|
...ANTI_THROTTLING_ARGS,
|
||
|
|
...extraArgs,
|
||
|
|
"about:blank",
|
||
|
|
]
|
||
|
|
if (headless) args.unshift("--headless=new", "--disable-gpu")
|
||
|
|
return spawn(chrome, args, { stdio: "ignore" })
|
||
|
|
}
|
||
|
|
|
||
|
|
export class CdpClient {
|
||
|
|
constructor(url) {
|
||
|
|
this.socket = new WebSocket(url)
|
||
|
|
this.nextId = 1
|
||
|
|
this.pending = new Map()
|
||
|
|
this.listeners = new Map()
|
||
|
|
}
|
||
|
|
|
||
|
|
async connect() {
|
||
|
|
await new Promise((resolveConnect, reject) => {
|
||
|
|
this.socket.addEventListener("open", resolveConnect, { once: true })
|
||
|
|
this.socket.addEventListener("error", reject, { once: true })
|
||
|
|
})
|
||
|
|
this.socket.addEventListener("message", (event) => {
|
||
|
|
const message = JSON.parse(String(event.data))
|
||
|
|
if (message.id) {
|
||
|
|
const pending = this.pending.get(message.id)
|
||
|
|
if (!pending) return
|
||
|
|
this.pending.delete(message.id)
|
||
|
|
if (message.error) pending.reject(new Error(message.error.message))
|
||
|
|
else pending.resolve(message.result ?? {})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
for (const listener of this.listeners.get(message.method) ?? []) listener(message.params ?? {})
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
send(method, params = {}) {
|
||
|
|
const id = this.nextId++
|
||
|
|
return new Promise((resolveSend, reject) => {
|
||
|
|
this.pending.set(id, { resolve: resolveSend, reject: reject })
|
||
|
|
this.socket.send(JSON.stringify({ id, method, params }))
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
on(method, listener) {
|
||
|
|
const listeners = this.listeners.get(method) ?? new Set()
|
||
|
|
listeners.add(listener)
|
||
|
|
this.listeners.set(method, listeners)
|
||
|
|
return () => listeners.delete(listener)
|
||
|
|
}
|
||
|
|
|
||
|
|
once(method, timeoutMs = 15_000) {
|
||
|
|
return new Promise((resolveEvent, reject) => {
|
||
|
|
const timeout = setTimeout(() => {
|
||
|
|
unsubscribe()
|
||
|
|
reject(new Error(`Timed out waiting for ${method}`))
|
||
|
|
}, timeoutMs)
|
||
|
|
const unsubscribe = this.on(method, (params) => {
|
||
|
|
clearTimeout(timeout)
|
||
|
|
unsubscribe()
|
||
|
|
resolveEvent(params)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
close() {
|
||
|
|
this.socket.close()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export const evaluateValue = async (client, expression) => {
|
||
|
|
const result = await client.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true })
|
||
|
|
return result.result?.value ?? null
|
||
|
|
}
|