import { decodeBase64 as decode } from "encoding/base64";
import cdp from "./deno-cri/index.js";
import { getBrowserExecutablePath } from "../puppeteer.ts";
import { Semaphore } from "../lib/semaphore.ts";
import { findOpenPort } from "../port.ts";
import { getNamedLifetime, ObjectWithLifetime } from "../lifetimes.ts";
import { sleep } from "../async.ts";
import { InternalError } from "../lib/error.ts";
import { getenv } from "../env.ts";
import { kRenderFileLifetime } from "../../config/constants.ts";
import { debug } from "../../deno_ral/log.ts";
import {
registerForExitCleanup,
unregisterForExitCleanup,
} from "../process.ts";
import { assert } from "testing/asserts";
async function waitForServer(port: number, timeout = 3000) {
const interval = 50;
let soFar = 0;
do {
try {
const response = await fetch(`http://localhost:${port}/json/list`);
if (response.status !== 200) {
throw new Error("");
}
return true;
} catch (_e) {
soFar += interval;
await new Promise((resolve) => setTimeout(resolve, interval));
}
} while (soFar < timeout);
return false;
}
const criSemaphore = new Semaphore(1);
export type CriClient = Awaited<ReturnType<typeof criClient>>;
export function withCriClient<T>(
fn: (client: CriClient) => Promise<T>,
appPath?: string,
port?: number,
): Promise<T> {
if (port === undefined) {
port = findOpenPort(9222);
}
return criSemaphore.runExclusive(async () => {
const lifetime = getNamedLifetime(kRenderFileLifetime);
if (lifetime === undefined) {
throw new InternalError("named lifetime render-file not found");
}
let client: CriClient;
if (lifetime.get("cri-client") === undefined) {
client = await criClient(appPath, port);
lifetime.attach({
client,
async cleanup() {
await client.close();
},
} as ObjectWithLifetime, "cri-client");
} else {
client = (lifetime.get("cri-client") as any).client as CriClient;
}
return await fn(client);
});
}
export async function criClient(appPath?: string, port?: number) {
if (port === undefined) {
port = findOpenPort(9222);
}
const app: string = appPath || await getBrowserExecutablePath();
const headlessMode = getenv("QUARTO_CHROMIUM_HEADLESS_MODE", "none");
const args = [
`--headless${headlessMode == "none" ? "" : "=" + headlessMode}`,
"--no-sandbox",
"--disable-gpu",
"--renderer-process-limit=1",
`--remote-debugging-port=${port}`,
];
const browser = new Deno.Command(app, {
args,
stdout: "piped",
stderr: "piped",
});
const cmd = browser.spawn();
const thisProcessId = registerForExitCleanup(cmd);
if (!(await waitForServer(port as number))) {
let msg = "Couldn't find open server.";
if (!(await cmd.status).success) {
debug(`[CHROMIUM path] : ${app}`);
debug(`[CHROMIUM cmd] : ${cmd}`);
const rawError = await cmd.stderr;
const reader = rawError.getReader();
const readerResult = await reader.read();
assert(readerResult.done);
const errorString = new TextDecoder().decode(readerResult.value!);
msg = msg + "\n" + `Chrome process error: ${errorString}`;
}
throw new Error(msg);
}
let client: any;
const result = {
close: async () => {
await client.close();
cmd.kill();
unregisterForExitCleanup(thisProcessId);
},
rawClient: () => client,
open: async (url: string) => {
const maxTries = 5;
for (let i = 0; i < maxTries; ++i) {
try {
client = await cdp({ port });
break;
} catch (e) {
if (i === maxTries - 1) {
throw e;
}
await sleep(100);
}
}
const { Network, Page } = client;
await Network.enable();
await Page.enable();
await Page.navigate({ url });
return new Promise((fulfill, _reject) => {
Page.loadEventFired(() => {
fulfill(null);
});
});
},
docQuerySelectorAll: async (cssSelector: string): Promise<number[]> => {
await client.DOM.enable();
const doc = await client.DOM.getDocument();
const nodeIds = await client.DOM.querySelectorAll({
nodeId: doc.root.nodeId,
selector: cssSelector,
});
return nodeIds.nodeIds;
},
contents: async (cssSelector: string): Promise<string[]> => {
const nodeIds = await result.docQuerySelectorAll(cssSelector);
return Promise.all(
nodeIds.map(async (nodeId: any) => {
return (await client.DOM.getOuterHTML({ nodeId })).outerHTML;
}),
);
},
screenshots: async (
cssSelector: string,
scale = 4,
): Promise<{ nodeId: number; data: Uint8Array }[]> => {
const nodeIds = await result.docQuerySelectorAll(cssSelector);
const lst: { nodeId: number; data: Uint8Array }[] = [];
for (const nodeId of nodeIds) {
let quad;
try {
quad = (await client.DOM.getContentQuads({ nodeId })).quads[0];
} catch (_e) {
continue;
}
const minX = Math.min(quad[0], quad[2], quad[4], quad[6]);
const maxX = Math.max(quad[0], quad[2], quad[4], quad[6]);
const minY = Math.min(quad[1], quad[3], quad[5], quad[7]);
const maxY = Math.max(quad[1], quad[3], quad[5], quad[7]);
try {
const screenshot = await client.Page.captureScreenshot({
clip: {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
scale,
},
fromSurface: true,
captureBeyondViewport: true,
});
const buf = decode(screenshot.data);
lst.push({ nodeId, data: buf });
} catch (_e) {
continue;
}
}
return lst;
},
};
return result;
}