Skip to content

Commit ace5b95

Browse files
committed
chore: install browser with @puppeteer/browsers
Browser installation relied on a postinstall script from puppeteer package which was unreliable with npx-import. For instance, if we have a workers project with browser binding that doesn't explicitly include `puppeteer`, the first time we run it it with `npx wrangler dev` and trigger the `launch` function, it will install the browser and cache it under `~/.cache/puppeteer`. But if for some reason we delete the cached browser (e.g., we run `npx puppeteer browsers clear`), and we try to run it the same way as above, it will fail with: ``` Error: Could not find Chrome (ver. 124.0.6367.207). This can occur if either 1. you did not perform an installation before running the script (e.g. `npx puppeteer browsers install chrome`) or 2. your cache path is incorrectly configured (which is: /Users/rfigueira/.cache/puppeteer). For (2), check out our guide on configuring puppeteer at https://pptr.dev/guides/configuration. at ChromeLauncher.resolveExecutablePath (/Users/rfigueira/.npm/_npx/9c191fc107e155e1/node_modules/puppeteer-core/lib/cjs/puppeteer/node/ProductLauncher.js:295:27) at ChromeLauncher.executablePath (/Users/rfigueira/.npm/_npx/9c191fc107e155e1/node_modules/puppeteer-core/lib/cjs/puppeteer/node/ChromeLauncher.js:209:25) at ChromeLauncher.computeLaunchArguments (/Users/rfigueira/.npm/_npx/9c191fc107e155e1/node_modules/puppeteer-core/lib/cjs/puppeteer/node/ChromeLauncher.js:89:37) at async ChromeLauncher.launch (/Users/rfigueira/.npm/_npx/9c191fc107e155e1/node_modules/puppeteer-core/lib/cjs/puppeteer/node/ProductLauncher.js:70:28) at async #handleLoopback (/Users/rfigueira/dev/tmp/puppeteer-test/node_modules/wrangler/node_modules/miniflare/dist/src/index.js:25418:25) ``` After deleting the browser, and because `npx-import` is already installed, when we run `npxImport("[email protected]")` it will just skip the installation, and therefore it won't call the `postinstall` script associated with that package that actually installs and caches the browser.
1 parent e985267 commit ace5b95

File tree

6 files changed

+254
-64
lines changed

6 files changed

+254
-64
lines changed

.changeset/nasty-banks-joke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"miniflare": minor
3+
---
4+
5+
Browser Rendering for local development now uses @puppeteer/browsers package instead of puppeteer

fixtures/browser-rendering/test/index.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { resolve } from "path";
44
import { afterAll, beforeAll, describe, expect, it } from "vitest";
55
import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived";
66

7-
describe("Local Browser", () => {
7+
describe.sequential("Local Browser", () => {
88
let ip: string,
99
port: number,
1010
stop: (() => Promise<unknown>) | undefined,

packages/miniflare/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"@cloudflare/workers-types": "^4.20250803.0",
6666
"@cloudflare/workflows-shared": "workspace:*",
6767
"@microsoft/api-extractor": "^7.52.8",
68+
"@puppeteer/browsers": "^2.10.6",
6869
"@types/debug": "^4.1.7",
6970
"@types/estree": "^1.0.0",
7071
"@types/glob-to-regexp": "^0.4.1",
@@ -93,7 +94,6 @@
9394
"http-cache-semantics": "^4.1.0",
9495
"kleur": "^4.1.5",
9596
"mime": "^3.0.0",
96-
"npx-import": "^1.1.4",
9797
"postal-mime": "^2.4.3",
9898
"pretty-bytes": "^6.0.0",
9999
"rimraf": "catalog:default",

packages/miniflare/src/index.ts

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import zlib from "zlib";
1414
import { checkMacOSVersion } from "@cloudflare/cli";
1515
import exitHook from "exit-hook";
1616
import { $ as colors$, green } from "kleur/colors";
17-
import { npxImport } from "npx-import";
1817
import stoppable from "stoppable";
1918
import {
2019
Dispatcher,
@@ -48,6 +47,7 @@ import {
4847
HELLO_WORLD_PLUGIN_NAME,
4948
HOST_CAPNP_CONNECT,
5049
KV_PLUGIN_NAME,
50+
launchBrowser,
5151
normaliseDurableObject,
5252
PLUGIN_ENTRIES,
5353
Plugins,
@@ -141,7 +141,7 @@ import type {
141141
Queue,
142142
R2Bucket,
143143
} from "@cloudflare/workers-types/experimental";
144-
import type { ChildProcess } from "child_process";
144+
import type { Process } from "@puppeteer/browsers";
145145

146146
const DEFAULT_HOST = "127.0.0.1";
147147
function getURLSafeHost(host: string) {
@@ -849,7 +849,7 @@ export class Miniflare {
849849
#log: Log;
850850

851851
// key is the browser wsEndpoint, value is the browser process
852-
#browserProcesses: Map<string, ChildProcess> = new Map();
852+
#browserProcesses: Map<string, Process> = new Map();
853853

854854
readonly #runtime?: Runtime;
855855
readonly #removeExitHook?: () => void;
@@ -1179,27 +1179,28 @@ export class Miniflare {
11791179
this.#log.logWithLevel(logLevel, message);
11801180
response = new Response(null, { status: 204 });
11811181
} else if (url.pathname === "/browser/launch") {
1182-
// Version should be kept in sync with the supported version at https://github.com/cloudflare/puppeteer?tab=readme-ov-file#workers-version-of-puppeteer-core
1183-
const puppeteer = await npxImport(
1184-
1185-
this.#log.warn.bind(this.#log)
1186-
);
1187-
1188-
// @ts-expect-error Puppeteer is dynamically installed, and so doesn't have types available
1189-
const browser = await puppeteer.launch({
1190-
headless: "old",
1191-
// workaround for CI environments, to avoid sandboxing issues
1192-
args: process.env.CI ? ["--no-sandbox"] : [],
1182+
const { browserProcess, wsEndpoint } = await launchBrowser({
1183+
// Puppeteer v22.8.2 supported chrome version:
1184+
// https://pptr.dev/supported-browsers#supported-browser-version-list
1185+
//
1186+
// It should match the supported chrome version for the upstream puppeteer
1187+
// version from which @cloudflare/puppeteer branched off, which is specified in:
1188+
// https://github.com/cloudflare/puppeteer/tree/v1.0.2?tab=readme-ov-file#workers-version-of-puppeteer-core
1189+
browserVersion: "124.0.6367.207",
1190+
log: this.#log,
1191+
tmpPath: this.#tmpPath,
1192+
});
1193+
browserProcess.nodeProcess.on("exit", () => {
1194+
this.#browserProcesses.delete(wsEndpoint);
11931195
});
1194-
const wsEndpoint = browser.wsEndpoint();
1195-
this.#browserProcesses.set(wsEndpoint, browser.process());
1196+
this.#browserProcesses.set(wsEndpoint, browserProcess);
11961197
response = new Response(wsEndpoint);
11971198
} else if (url.pathname === "/browser/status") {
11981199
const wsEndpoint = url.searchParams.get("wsEndpoint");
11991200
assert(wsEndpoint !== null, "Missing wsEndpoint query parameter");
12001201
const process = this.#browserProcesses.get(wsEndpoint);
12011202
const status = {
1202-
stopped: !process || process.exitCode !== null,
1203+
stopped: !process,
12031204
};
12041205
response = new Response(JSON.stringify(status), {
12051206
headers: { "Content-Type": "application/json" },
@@ -1817,11 +1818,8 @@ export class Miniflare {
18171818
}
18181819

18191820
async #assembleAndUpdateConfig() {
1820-
for (const [wsEndpoint, process] of this.#browserProcesses.entries()) {
1821-
// .close() isn't enough
1822-
process.kill("SIGKILL");
1823-
this.#browserProcesses.delete(wsEndpoint);
1824-
}
1821+
await this.#closeBrowserProcesses();
1822+
18251823
// This function must be run with `#runtimeMutex` held
18261824
const initial = !this.#runtimeEntryURL;
18271825
assert(this.#runtime !== undefined);
@@ -1988,6 +1986,18 @@ export class Miniflare {
19881986
}
19891987
}
19901988

1989+
async #closeBrowserProcesses() {
1990+
await Promise.all(
1991+
Array.from(this.#browserProcesses.values()).map((process) =>
1992+
process.close()
1993+
)
1994+
);
1995+
assert(
1996+
this.#browserProcesses.size === 0,
1997+
"Not all browser processes were closed"
1998+
);
1999+
}
2000+
19912001
async #waitForReady(disposing = false) {
19922002
// If `#init()` threw, we'd like to propagate the error here, so `await` it.
19932003
// Note we can't use `async`/`await` with getters. We'd also like to wait
@@ -2528,11 +2538,7 @@ export class Miniflare {
25282538
try {
25292539
await this.#waitForReady(/* disposing */ true);
25302540
} finally {
2531-
for (const [wsEndpoint, process] of this.#browserProcesses.entries()) {
2532-
// .close() isn't enough
2533-
process.kill("SIGKILL");
2534-
this.#browserProcesses.delete(wsEndpoint);
2535-
}
2541+
await this.#closeBrowserProcesses();
25362542

25372543
// Remove exit hook, we're cleaning up what they would've cleaned up now
25382544
this.#removeExitHook?.();

packages/miniflare/src/plugins/browser-rendering/index.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
1+
import fs from "fs";
2+
import os from "os";
3+
import path from "path";
4+
import { brandColor } from "@cloudflare/cli/colors";
5+
import { spinner } from "@cloudflare/cli/interactive";
6+
import {
7+
Browser,
8+
CDP_WEBSOCKET_ENDPOINT_REGEX,
9+
detectBrowserPlatform,
10+
install,
11+
launch,
12+
resolveBuildId,
13+
} from "@puppeteer/browsers";
14+
import { dim } from "kleur/colors";
115
import BROWSER_RENDERING_WORKER from "worker:browser-rendering/binding";
216
import { z } from "zod";
317
import { kVoid } from "../../runtime";
18+
import { Log } from "../../shared";
419
import {
520
getUserBindingServiceName,
621
Plugin,
@@ -99,3 +114,118 @@ export const BROWSER_RENDERING_PLUGIN: Plugin<
99114
];
100115
},
101116
};
117+
118+
export async function launchBrowser({
119+
browserVersion,
120+
log,
121+
tmpPath,
122+
}: {
123+
browserVersion: string;
124+
log: Log;
125+
tmpPath: string;
126+
}) {
127+
const platform = detectBrowserPlatform();
128+
if (!platform) {
129+
throw new Error("The current platform is not supported.");
130+
}
131+
const browser = Browser.CHROME;
132+
const sessionId = crypto.randomUUID();
133+
134+
const s = spinner();
135+
let startedDownloading = false;
136+
137+
const { executablePath } = await install({
138+
browser,
139+
platform,
140+
cacheDir: path.join(os.homedir(), ".cache", "miniflare"),
141+
buildId: await resolveBuildId(browser, platform, version),
142+
downloadProgressCallback: (downloadedBytes, totalBytes) => {
143+
if (!startedDownloading) {
144+
s.start(`Downloading browser...`);
145+
startedDownloading = true;
146+
}
147+
const progress = Math.round((downloadedBytes / totalBytes) * 100);
148+
s.update(`Downloading browser... ${progress}%`);
149+
},
150+
});
151+
152+
if (startedDownloading) {
153+
s.stop(`${brandColor("downloaded")} ${dim(`browser`)}`);
154+
log.debug(`${browser} ${browserVersion} available at ${executablePath}`);
155+
}
156+
157+
const tempUserData = path.join(
158+
tmpPath,
159+
"browser-rendering",
160+
`profile-${sessionId}`
161+
);
162+
await fs.promises.mkdir(tempUserData, { recursive: true });
163+
164+
// https://github.com/puppeteer/puppeteer/blob/44516936ad4a878f9a89e835a9fa7b04360d6fb9/packages/puppeteer-core/src/node/ChromeLauncher.ts#L156
165+
const disabledFeatures = [
166+
"Translate",
167+
// AcceptCHFrame disabled because of crbug.com/1348106.
168+
"AcceptCHFrame",
169+
"MediaRouter",
170+
"OptimizationHints",
171+
"ProcessPerSiteUpToMainFrameThreshold",
172+
"IsolateSandboxedIframes",
173+
];
174+
const args = [
175+
"--allow-pre-commit-input",
176+
"--disable-background-networking",
177+
"--disable-background-timer-throttling",
178+
"--disable-backgrounding-occluded-windows",
179+
"--disable-breakpad",
180+
"--disable-client-side-phishing-detection",
181+
"--disable-component-extensions-with-background-pages",
182+
"--disable-crash-reporter", // No crash reporting in CfT.
183+
"--disable-default-apps",
184+
"--disable-dev-shm-usage",
185+
"--disable-hang-monitor",
186+
"--disable-infobars",
187+
"--disable-ipc-flooding-protection",
188+
"--disable-popup-blocking",
189+
"--disable-prompt-on-repost",
190+
"--disable-renderer-backgrounding",
191+
"--disable-search-engine-choice-screen",
192+
"--disable-sync",
193+
"--enable-automation",
194+
"--export-tagged-pdf",
195+
"--force-color-profile=srgb",
196+
"--generate-pdf-document-outline",
197+
"--metrics-recording-only",
198+
"--no-first-run",
199+
"--password-store=basic",
200+
"--use-mock-keychain",
201+
`--disable-features=${disabledFeatures.join(",")}`,
202+
"--headless=new",
203+
"--hide-scrollbars",
204+
"--mute-audio",
205+
"--disable-extensions",
206+
"about:blank",
207+
"--remote-debugging-port=0",
208+
`--user-data-dir=${tempUserData}`,
209+
];
210+
211+
const browserProcess = launch({
212+
executablePath,
213+
args: process.env.CI ? [...args, "--no-sandbox"] : args,
214+
handleSIGTERM: false,
215+
dumpio: false,
216+
pipe: false,
217+
onExit: async () => {
218+
await fs.promises
219+
.rm(tempUserData, { recursive: true, force: true })
220+
.catch((e) => {
221+
log.debug(
222+
`Unable to remove Chrome user data directory: ${String(e)}`
223+
);
224+
});
225+
},
226+
});
227+
const wsEndpoint = await browserProcess.waitForLineOutput(
228+
CDP_WEBSOCKET_ENDPOINT_REGEX
229+
);
230+
return { sessionId, browserProcess, wsEndpoint };
231+
}

0 commit comments

Comments
 (0)