Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/core/cri/cri.ts
3583 views
1
/**
2
* cri.ts
3
*
4
* Chrome Remote Interface
5
*
6
* Copyright (c) 2022 Posit Software, PBC.
7
*/
8
9
import { decodeBase64 as decode } from "encoding/base64";
10
import cdp from "./deno-cri/index.js";
11
import { getBrowserExecutablePath } from "../puppeteer.ts";
12
import { Semaphore } from "../lib/semaphore.ts";
13
import { findOpenPort } from "../port.ts";
14
import { getNamedLifetime, ObjectWithLifetime } from "../lifetimes.ts";
15
import { sleep } from "../async.ts";
16
import { InternalError } from "../lib/error.ts";
17
import { getenv } from "../env.ts";
18
import { kRenderFileLifetime } from "../../config/constants.ts";
19
import { debug } from "../../deno_ral/log.ts";
20
import {
21
registerForExitCleanup,
22
unregisterForExitCleanup,
23
} from "../process.ts";
24
import { assert } from "testing/asserts";
25
26
async function waitForServer(port: number, timeout = 3000) {
27
const interval = 50;
28
let soFar = 0;
29
30
do {
31
try {
32
const response = await fetch(`http://localhost:${port}/json/list`);
33
if (response.status !== 200) {
34
throw new Error("");
35
}
36
return true;
37
} catch (_e) {
38
soFar += interval;
39
await new Promise((resolve) => setTimeout(resolve, interval));
40
}
41
} while (soFar < timeout);
42
return false;
43
}
44
45
const criSemaphore = new Semaphore(1);
46
47
export type CriClient = Awaited<ReturnType<typeof criClient>>;
48
49
export function withCriClient<T>(
50
fn: (client: CriClient) => Promise<T>,
51
appPath?: string,
52
port?: number,
53
): Promise<T> {
54
if (port === undefined) {
55
port = findOpenPort(9222);
56
}
57
58
return criSemaphore.runExclusive(async () => {
59
const lifetime = getNamedLifetime(kRenderFileLifetime);
60
if (lifetime === undefined) {
61
throw new InternalError("named lifetime render-file not found");
62
}
63
let client: CriClient;
64
if (lifetime.get("cri-client") === undefined) {
65
client = await criClient(appPath, port);
66
lifetime.attach({
67
client,
68
async cleanup() {
69
await client.close();
70
},
71
} as ObjectWithLifetime, "cri-client");
72
} else {
73
// deno-lint-ignore no-explicit-any
74
client = (lifetime.get("cri-client") as any).client as CriClient;
75
}
76
77
// this must be awaited since we're in runExclusive.
78
return await fn(client);
79
});
80
}
81
82
export async function criClient(appPath?: string, port?: number) {
83
if (port === undefined) {
84
port = findOpenPort(9222);
85
}
86
const app: string = appPath || await getBrowserExecutablePath();
87
88
// Allow to adapt the headless mode depending on the Chrome version
89
const headlessMode = getenv("QUARTO_CHROMIUM_HEADLESS_MODE", "none");
90
91
const args = [
92
// TODO: Chrome v128 changed the default from --headless=old to --headless=new
93
// in 2024-08. Old headless mode was effectively a separate browser render,
94
// and while more performant did not share the same browser implementation as
95
// headful Chrome. New headless mode will likely be useful to some, but in Quarto use cases
96
// like printing to PDF or screenshoting, we need more work to
97
// move to the new mode. We'll use `--headless=old` as the default for now
98
// until the new mode is more stable, or until we really pin a version as default to be used.
99
// This is also impacting in chromote and pagedown R packages and we could keep syncing with them.
100
// EDIT: 17/01/2025 - old mode is gone in Chrome 132. Let's default to new mode to unbreak things.
101
// Best course of action is to pin a version of Chrome and use the chrome-headless-shell more adapted to our need.
102
// ref: https://developer.chrome.com/blog/chrome-headless-shell
103
`--headless${headlessMode == "none" ? "" : "=" + headlessMode}`,
104
"--no-sandbox",
105
"--disable-gpu",
106
"--renderer-process-limit=1",
107
`--remote-debugging-port=${port}`,
108
];
109
const browser = new Deno.Command(app, {
110
args,
111
stdout: "piped",
112
stderr: "piped",
113
});
114
115
const cmd = browser.spawn();
116
// Register for cleanup inside exitWithCleanup() in case something goes wrong
117
const thisProcessId = registerForExitCleanup(cmd);
118
119
if (!(await waitForServer(port as number))) {
120
let msg = "Couldn't find open server.";
121
// Printing more error information if chrome process errored
122
if (!(await cmd.status).success) {
123
debug(`[CHROMIUM path] : ${app}`);
124
debug(`[CHROMIUM cmd] : ${cmd}`);
125
const rawError = await cmd.stderr;
126
const reader = rawError.getReader();
127
const readerResult = await reader.read();
128
assert(readerResult.done);
129
const errorString = new TextDecoder().decode(readerResult.value!);
130
msg = msg + "\n" + `Chrome process error: ${errorString}`;
131
}
132
133
throw new Error(msg);
134
}
135
136
// deno-lint-ignore no-explicit-any
137
let client: any;
138
139
const result = {
140
close: async () => {
141
await client.close();
142
// FIXME: 2024-10
143
// We have a bug where `client.close()` doesn't return properly and we don't go below
144
// meaning the `browser` process is not killed here, and it will be handled in exitWithCleanup().
145
146
cmd.kill(); // Chromium headless won't terminate on its own, so we need to send kill signal
147
unregisterForExitCleanup(thisProcessId); // All went well so not need to cleanup on quarto exit
148
},
149
150
rawClient: () => client,
151
152
open: async (url: string) => {
153
const maxTries = 5;
154
for (let i = 0; i < maxTries; ++i) {
155
try {
156
client = await cdp({ port });
157
break;
158
} catch (e) {
159
if (i === maxTries - 1) {
160
throw e;
161
}
162
// sleep(0) caused 42/100 crashes
163
// sleep(1) caused 42/100 crashes
164
// sleep(2) caused 15/100 crashes
165
// sleep(3) caused 13/100 crashes
166
// sleep(4) caused 8/100 crashes
167
// sleep(5) caused 1/100 crashes
168
// sleep(6) caused 1/100 crashes
169
// sleep(7) caused 1/100 crashes
170
// sleep(8) caused 1/100 crashes
171
// sleep(9) caused 0/100 crashes
172
// sleep(10) caused 0/100 crashes
173
// sleep(11) caused 0/100 crashes
174
// sleep(12) caused 0/100 crashes
175
// sleep(13) caused 1/100 crashes
176
// sleep(14) caused 1/100 crashes
177
// sleep(15) caused 0/100 crashes
178
// sleep(16) caused 0/100 crashes
179
// sleep(17) caused 0/100 crashes
180
181
// https://carlos-scheidegger.quarto.pub/failure-rates-in-cri-initialization/
182
// suggests that 44ms is a good value. We use 100ms to try and account for slower
183
// machines.
184
await sleep(100);
185
}
186
}
187
const { Network, Page } = client;
188
await Network.enable();
189
await Page.enable();
190
await Page.navigate({ url });
191
return new Promise((fulfill, _reject) => {
192
Page.loadEventFired(() => {
193
fulfill(null);
194
});
195
});
196
},
197
198
docQuerySelectorAll: async (cssSelector: string): Promise<number[]> => {
199
await client.DOM.enable();
200
const doc = await client.DOM.getDocument();
201
const nodeIds = await client.DOM.querySelectorAll({
202
nodeId: doc.root.nodeId,
203
selector: cssSelector,
204
});
205
return nodeIds.nodeIds;
206
},
207
208
contents: async (cssSelector: string): Promise<string[]> => {
209
const nodeIds = await result.docQuerySelectorAll(cssSelector);
210
return Promise.all(
211
// deno-lint-ignore no-explicit-any
212
nodeIds.map(async (nodeId: any) => {
213
return (await client.DOM.getOuterHTML({ nodeId })).outerHTML;
214
}),
215
);
216
},
217
218
// defaults to screenshotting at 4x scale = 392dpi.
219
screenshots: async (
220
cssSelector: string,
221
scale = 4,
222
): Promise<{ nodeId: number; data: Uint8Array }[]> => {
223
const nodeIds = await result.docQuerySelectorAll(cssSelector);
224
const lst: { nodeId: number; data: Uint8Array }[] = [];
225
for (const nodeId of nodeIds) {
226
// the docs say that inline elements might return more than one box
227
// TODO what do we do in that case?
228
let quad;
229
try {
230
quad = (await client.DOM.getContentQuads({ nodeId })).quads[0];
231
} catch (_e) {
232
// TODO report error?
233
continue;
234
}
235
const minX = Math.min(quad[0], quad[2], quad[4], quad[6]);
236
const maxX = Math.max(quad[0], quad[2], quad[4], quad[6]);
237
const minY = Math.min(quad[1], quad[3], quad[5], quad[7]);
238
const maxY = Math.max(quad[1], quad[3], quad[5], quad[7]);
239
try {
240
const screenshot = await client.Page.captureScreenshot({
241
clip: {
242
x: minX,
243
y: minY,
244
width: maxX - minX,
245
height: maxY - minY,
246
scale,
247
},
248
fromSurface: true,
249
captureBeyondViewport: true,
250
});
251
const buf = decode(screenshot.data);
252
lst.push({ nodeId, data: buf });
253
} catch (_e) {
254
// TODO report error?
255
continue;
256
}
257
}
258
return lst;
259
},
260
};
261
return result;
262
}
263
264