Skip to content

Commit 059a39e

Browse files
authored
fix: polls miniflare to check browser process (#10041)
* fix: polls miniflare to check browser process Playwright relies on a WebSocket close event that is never triggered in local dev. As a workaround, we poll miniflare to check if the browser is still up and running, otherwise we close the websocket explictly. Fixes: #9945 * chore: fix review suggestions * test: ensure websocket closes after Browser.close * chore: add session reuse support See: https://developers.cloudflare.com/browser-rendering/workers-bindings/reuse-sessions/ * chore: fix review suggestions * test: add local browser tests with playwright * chore: fix review suggestions and add changeset
1 parent 031c7f6 commit 059a39e

File tree

10 files changed

+429
-96
lines changed

10 files changed

+429
-96
lines changed

.changeset/poor-days-add.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+
Add @cloudflare/plywright support for Browser Rendering local mode

fixtures/browser-rendering/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"test:ci": "vitest run"
1111
},
1212
"devDependencies": {
13+
"@cloudflare/playwright": "^0.0.10",
1314
"@cloudflare/puppeteer": "^1.0.2",
1415
"@cloudflare/vitest-pool-workers": "workspace:*",
1516
"@types/node": "catalog:default",
Lines changed: 6 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,15 @@
1-
import puppeteer from "@cloudflare/puppeteer";
1+
import playwrightWorker from "./playwright";
2+
import puppeteerWorker from "./puppeteer";
23

34
export default {
45
async fetch(request, env): Promise<Response> {
56
const { searchParams } = new URL(request.url);
6-
let url = searchParams.get("url");
7-
let action = searchParams.get("action");
8-
if (url) {
9-
url = new URL(url).toString(); // normalize
10-
switch (action) {
11-
case "select": {
12-
const browser = await puppeteer.launch(env.MYBROWSER);
13-
const page = await browser.newPage();
14-
await page.goto(url);
15-
const h1Text = await page.$eval("h1", (el) => el.textContent.trim());
16-
return new Response(h1Text);
17-
}
7+
let lib = searchParams.get("lib");
188

19-
case "alter": {
20-
const browser = await puppeteer.launch(env.MYBROWSER);
21-
const page = await browser.newPage();
22-
23-
await page.goto(url); // change to your target URL
24-
25-
await page.evaluate(() => {
26-
const paragraph = document.querySelector("p");
27-
if (paragraph) {
28-
paragraph.textContent = "New paragraph text set by Puppeteer!";
29-
}
30-
});
31-
32-
const pText = await page.$eval("p", (el) => el.textContent.trim());
33-
return new Response(pText);
34-
}
35-
}
36-
37-
let img = await env.BROWSER_KV_DEMO.get(url, { type: "arrayBuffer" });
38-
if (img === null) {
39-
const browser = await puppeteer.launch(env.MYBROWSER);
40-
const page = await browser.newPage();
41-
await page.goto(url);
42-
img = (await page.screenshot()) as Buffer;
43-
await env.BROWSER_KV_DEMO.put(url, img, {
44-
expirationTtl: 60 * 60 * 24,
45-
});
46-
await browser.close();
47-
}
48-
return new Response(img, {
49-
headers: {
50-
"content-type": "image/jpeg",
51-
},
52-
});
9+
if (lib === "playwright") {
10+
return playwrightWorker.fetch(request, env);
5311
} else {
54-
return new Response("Please add an ?url=https://example.com/ parameter");
12+
return puppeteerWorker.fetch(request, env);
5513
}
5614
},
5715
} satisfies ExportedHandler<Env>;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import playwright from "@cloudflare/playwright";
2+
3+
export default {
4+
async fetch(request, env): Promise<Response> {
5+
const { searchParams } = new URL(request.url);
6+
let url = searchParams.get("url");
7+
let action = searchParams.get("action");
8+
if (url) {
9+
url = new URL(url).toString(); // normalize
10+
switch (action) {
11+
case "select": {
12+
await using browser = await playwright.launch(env.MYBROWSER);
13+
const page = await browser.newPage();
14+
await page.goto(url);
15+
const h1Text = await page.locator("h1").textContent();
16+
return new Response(h1Text);
17+
}
18+
19+
case "alter": {
20+
await using browser = await playwright.launch(env.MYBROWSER);
21+
const page = await browser.newPage();
22+
23+
await page.goto(url); // change to your target URL
24+
25+
await page
26+
.locator("p")
27+
.first()
28+
.evaluate((paragraph) => {
29+
paragraph.textContent = "New paragraph text set by Playwright!";
30+
});
31+
32+
const pText = await page.locator("p").first().textContent();
33+
return new Response(pText);
34+
}
35+
}
36+
37+
let img = await env.BROWSER_KV_DEMO.get(url, { type: "arrayBuffer" });
38+
if (img === null) {
39+
await using browser = await playwright.launch(env.MYBROWSER);
40+
const page = await browser.newPage();
41+
await page.goto(url);
42+
img = (await page.screenshot()) as Buffer;
43+
await env.BROWSER_KV_DEMO.put(url, img, {
44+
expirationTtl: 60 * 60 * 24,
45+
});
46+
}
47+
return new Response(img, {
48+
headers: {
49+
"content-type": "image/jpeg",
50+
},
51+
});
52+
} else {
53+
return new Response("Please add an ?url=https://example.com/ parameter");
54+
}
55+
},
56+
} satisfies ExportedHandler<Env>;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import puppeteer from "@cloudflare/puppeteer";
2+
3+
export default {
4+
async fetch(request, env): Promise<Response> {
5+
const { searchParams } = new URL(request.url);
6+
let url = searchParams.get("url");
7+
let action = searchParams.get("action");
8+
if (url) {
9+
url = new URL(url).toString(); // normalize
10+
switch (action) {
11+
case "select": {
12+
const browser = await puppeteer.launch(env.MYBROWSER);
13+
const page = await browser.newPage();
14+
await page.goto(url);
15+
const h1Text = await page.$eval("h1", (el) => el.textContent.trim());
16+
return new Response(h1Text);
17+
}
18+
19+
case "alter": {
20+
const browser = await puppeteer.launch(env.MYBROWSER);
21+
const page = await browser.newPage();
22+
23+
await page.goto(url); // change to your target URL
24+
25+
await page.evaluate(() => {
26+
const paragraph = document.querySelector("p");
27+
if (paragraph) {
28+
paragraph.textContent = "New paragraph text set by Puppeteer!";
29+
}
30+
});
31+
32+
const pText = await page.$eval("p", (el) => el.textContent.trim());
33+
return new Response(pText);
34+
}
35+
}
36+
37+
let img = await env.BROWSER_KV_DEMO.get(url, { type: "arrayBuffer" });
38+
if (img === null) {
39+
const browser = await puppeteer.launch(env.MYBROWSER);
40+
const page = await browser.newPage();
41+
await page.goto(url);
42+
img = (await page.screenshot()) as Buffer;
43+
await env.BROWSER_KV_DEMO.put(url, img, {
44+
expirationTtl: 60 * 60 * 24,
45+
});
46+
await browser.close();
47+
}
48+
return new Response(img, {
49+
headers: {
50+
"content-type": "image/jpeg",
51+
},
52+
});
53+
} else {
54+
return new Response("Please add an ?url=https://example.com/ parameter");
55+
}
56+
},
57+
} satisfies ExportedHandler<Env>;

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

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,31 @@ describe("Local Browser", () => {
3737
return text;
3838
}
3939

40-
it("Doesn't run a browser, just testing that the worker is running!", async () => {
41-
await expect(fetchText(`http://${ip}:${port}/`)).resolves.toEqual(
42-
"Please add an ?url=https://example.com/ parameter"
43-
);
44-
});
40+
for (const lib of ["puppeteer", "playwright"]) {
41+
describe(`using @cloudflare/${lib}`, () => {
42+
it("Doesn't run a browser, just testing that the worker is running!", async () => {
43+
await expect(
44+
fetchText(`http://${ip}:${port}/?lib=${lib}`)
45+
).resolves.toEqual("Please add an ?url=https://example.com/ parameter");
46+
});
4547

46-
it("Run a browser, and check h1 text content", async () => {
47-
await expect(
48-
fetchText(`http://${ip}:${port}/?url=https://example.com&action=select`)
49-
).resolves.toEqual("Example Domain");
50-
});
48+
it("Run a browser, and check h1 text content", async () => {
49+
await expect(
50+
fetchText(
51+
`http://${ip}:${port}/?lib=${lib}&url=https://example.com&action=select`
52+
)
53+
).resolves.toEqual("Example Domain");
54+
});
5155

52-
it("Run a browser, and check p text content", async () => {
53-
await expect(
54-
fetchText(`http://${ip}:${port}/?url=https://example.com&action=alter`)
55-
).resolves.toEqual("New paragraph text set by Puppeteer!");
56-
});
56+
it("Run a browser, and check p text content", async () => {
57+
await expect(
58+
fetchText(
59+
`http://${ip}:${port}/?lib=${lib}&url=https://example.com&action=alter`
60+
)
61+
).resolves.toEqual(
62+
`New paragraph text set by ${lib === "playwright" ? "Playwright" : "Puppeteer"}!`
63+
);
64+
});
65+
});
66+
}
5767
});

packages/miniflare/src/index.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -900,10 +900,8 @@ export class Miniflare {
900900
#workerOpts: PluginWorkerOptions[];
901901
#log: Log;
902902

903-
#browsers: Set<{
904-
wsEndpoint: () => string;
905-
process: () => ChildProcess;
906-
}> = new Set();
903+
// key is the browser wsEndpoint, value is the browser process
904+
#browserProcesses: Map<string, ChildProcess> = new Map();
907905

908906
readonly #runtime?: Runtime;
909907
readonly #removeExitHook?: () => void;
@@ -1304,10 +1302,24 @@ export class Miniflare {
13041302
);
13051303

13061304
// @ts-expect-error Puppeteer is dynamically installed, and so doesn't have types available
1307-
const browser = await puppeteer.launch({ headless: "old" });
1308-
this.#browsers.add(browser);
1309-
1310-
response = new Response(browser.wsEndpoint());
1305+
const browser = await puppeteer.launch({
1306+
headless: "old",
1307+
// workaround for CI environments, to avoid sandboxing issues
1308+
args: process.env.CI ? ["--no-sandbox"] : [],
1309+
});
1310+
const wsEndpoint = browser.wsEndpoint();
1311+
this.#browserProcesses.set(wsEndpoint, browser.process());
1312+
response = new Response(wsEndpoint);
1313+
} else if (url.pathname === "/browser/status") {
1314+
const wsEndpoint = url.searchParams.get("wsEndpoint");
1315+
assert(wsEndpoint !== null, "Missing wsEndpoint query parameter");
1316+
const process = this.#browserProcesses.get(wsEndpoint);
1317+
const status = {
1318+
stopped: !process || process.exitCode !== null,
1319+
};
1320+
response = new Response(JSON.stringify(status), {
1321+
headers: { "Content-Type": "application/json" },
1322+
});
13111323
} else if (url.pathname === "/core/store-temp-file") {
13121324
const prefix = url.searchParams.get("prefix");
13131325
const folder = prefix ? `files/${prefix}` : "files";
@@ -1958,9 +1970,10 @@ export class Miniflare {
19581970
}
19591971

19601972
async #assembleAndUpdateConfig() {
1961-
for (const browser of this.#browsers) {
1973+
for (const [wsEndpoint, process] of this.#browserProcesses.entries()) {
19621974
// .close() isn't enough
1963-
await browser.process().kill("SIGKILL");
1975+
process.kill("SIGKILL");
1976+
this.#browserProcesses.delete(wsEndpoint);
19641977
}
19651978
// This function must be run with `#runtimeMutex` held
19661979
const initial = !this.#runtimeEntryURL;
@@ -2662,9 +2675,10 @@ export class Miniflare {
26622675
try {
26632676
await this.#waitForReady(/* disposing */ true);
26642677
} finally {
2665-
for (const browser of this.#browsers) {
2678+
for (const [wsEndpoint, process] of this.#browserProcesses.entries()) {
26662679
// .close() isn't enough
2667-
await browser.process().kill("SIGKILL");
2680+
process.kill("SIGKILL");
2681+
this.#browserProcesses.delete(wsEndpoint);
26682682
}
26692683

26702684
// Remove exit hook, we're cleaning up what they would've cleaned up now

0 commit comments

Comments
 (0)