Skip to content

Commit b02843f

Browse files
authored
chore: implement local browser rendering /v1/session endpoint (#10220)
* chore: implement local browser rendering /v1/session endpoint * fix: ensure websocket is closed * test: ensure puppeteer is pre-installed for miniflare tests
1 parent 2e8eb24 commit b02843f

File tree

9 files changed

+444
-61
lines changed

9 files changed

+444
-61
lines changed

.changeset/petite-paws-allow.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 /v1/session endpoint for Browser Rendering local mode

fixtures/browser-rendering/src/playwright.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,24 @@ export default {
3232
const pText = await page.locator("p").first().textContent();
3333
return new Response(pText);
3434
}
35+
36+
case "disconnect": {
37+
const { sessionId } = await playwright.acquire(env.MYBROWSER);
38+
const browser = await playwright.connect(env.MYBROWSER, sessionId);
39+
// closing a browser obtained with playwright.connect actually disconnects
40+
// (it doesn's close the porcess)
41+
await browser.close();
42+
const sessionInfo = await playwright
43+
.sessions(env.MYBROWSER)
44+
.then((sessions) =>
45+
sessions.find((s) => s.sessionId === sessionId)
46+
);
47+
return new Response(
48+
sessionInfo.connectionId
49+
? "Browser not disconnected"
50+
: "Browser disconnected"
51+
);
52+
}
3553
}
3654

3755
let img = await env.BROWSER_KV_DEMO.get(url, { type: "arrayBuffer" });

fixtures/browser-rendering/src/puppeteer.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,22 @@ export default {
3232
const pText = await page.$eval("p", (el) => el.textContent.trim());
3333
return new Response(pText);
3434
}
35+
36+
case "disconnect": {
37+
const browser = await puppeteer.launch(env.MYBROWSER);
38+
const sessionId = browser.sessionId();
39+
await browser.disconnect();
40+
const sessionInfo = await puppeteer
41+
.sessions(env.MYBROWSER)
42+
.then((sessions) =>
43+
sessions.find((s) => s.sessionId === sessionId)
44+
);
45+
return new Response(
46+
sessionInfo.connectionId
47+
? "Browser not disconnected"
48+
: "Browser disconnected"
49+
);
50+
}
3551
}
3652

3753
let img = await env.BROWSER_KV_DEMO.get(url, { type: "arrayBuffer" });

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ describe("Local Browser", () => {
6262
`New paragraph text set by ${lib === "playwright" ? "Playwright" : "Puppeteer"}!`
6363
);
6464
});
65+
66+
it("Disconnect a browser, and check its session connection status", async () => {
67+
await expect(
68+
fetchText(
69+
`http://${ip}:${port}/?lib=${lib}&url=https://example.com&action=disconnect`
70+
)
71+
).resolves.toEqual(`Browser disconnected`);
72+
});
6573
});
6674
}
6775
});

packages/miniflare/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
"npx-import": "^1.1.4",
9797
"postal-mime": "^2.4.3",
9898
"pretty-bytes": "^6.0.0",
99+
"puppeteer": "22.8.2",
99100
"rimraf": "catalog:default",
100101
"source-map": "^0.6.1",
101102
"ts-dedent": "^2.2.0",

packages/miniflare/src/index.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -851,7 +851,7 @@ export class Miniflare {
851851
#workerOpts: PluginWorkerOptions[];
852852
#log: Log;
853853

854-
// key is the browser wsEndpoint, value is the browser process
854+
// key is the browser session ID, value is the browser process
855855
#browserProcesses: Map<string, ChildProcess> = new Map();
856856

857857
readonly #runtime?: Runtime;
@@ -1265,19 +1265,23 @@ export class Miniflare {
12651265
// workaround for CI environments, to avoid sandboxing issues
12661266
args: process.env.CI ? ["--no-sandbox"] : [],
12671267
});
1268+
const sessionId = crypto.randomUUID();
1269+
const browserProcess = browser.process();
1270+
browserProcess.on("exit", () => {
1271+
this.#browserProcesses.delete(sessionId);
1272+
});
12681273
const wsEndpoint = browser.wsEndpoint();
1269-
this.#browserProcesses.set(wsEndpoint, browser.process());
1270-
response = new Response(wsEndpoint);
1274+
const startTime = Date.now();
1275+
this.#browserProcesses.set(sessionId, browserProcess);
1276+
response = Response.json({ wsEndpoint, sessionId, startTime });
12711277
} else if (url.pathname === "/browser/status") {
1272-
const wsEndpoint = url.searchParams.get("wsEndpoint");
1273-
assert(wsEndpoint !== null, "Missing wsEndpoint query parameter");
1274-
const process = this.#browserProcesses.get(wsEndpoint);
1275-
const status = {
1276-
stopped: !process || process.exitCode !== null,
1277-
};
1278-
response = new Response(JSON.stringify(status), {
1279-
headers: { "Content-Type": "application/json" },
1280-
});
1278+
const sessionId = url.searchParams.get("sessionId");
1279+
assert(sessionId !== null, "Missing sessionId query parameter");
1280+
const process = this.#browserProcesses.get(sessionId);
1281+
response = new Response(null, { status: process ? 200 : 410 });
1282+
} else if (url.pathname === "/browser/sessionIds") {
1283+
const sessionIds = this.#browserProcesses.keys();
1284+
response = Response.json(Array.from(sessionIds));
12811285
} else if (url.pathname === "/core/store-temp-file") {
12821286
const prefix = url.searchParams.get("prefix");
12831287
const folder = prefix ? `files/${prefix}` : "files";
@@ -2001,10 +2005,10 @@ export class Miniflare {
20012005
}
20022006

20032007
async #assembleAndUpdateConfig() {
2004-
for (const [wsEndpoint, process] of this.#browserProcesses.entries()) {
2008+
for (const [sessionId, process] of this.#browserProcesses.entries()) {
20052009
// .close() isn't enough
20062010
process.kill("SIGKILL");
2007-
this.#browserProcesses.delete(wsEndpoint);
2011+
this.#browserProcesses.delete(sessionId);
20082012
}
20092013
// This function must be run with `#runtimeMutex` held
20102014
const initial = !this.#runtimeEntryURL;
@@ -2709,10 +2713,10 @@ export class Miniflare {
27092713
try {
27102714
await this.#waitForReady(/* disposing */ true);
27112715
} finally {
2712-
for (const [wsEndpoint, process] of this.#browserProcesses.entries()) {
2716+
for (const [sessionId, process] of this.#browserProcesses.entries()) {
27132717
// .close() isn't enough
27142718
process.kill("SIGKILL");
2715-
this.#browserProcesses.delete(wsEndpoint);
2719+
this.#browserProcesses.delete(sessionId);
27162720
}
27172721

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

packages/miniflare/src/workers/browser-rendering/binding.worker.ts

Lines changed: 66 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,28 @@ function isClosed(ws: WebSocket | undefined): boolean {
1212
return !ws || ws.readyState === WebSocket.CLOSED;
1313
}
1414

15+
export type SessionInfo = {
16+
wsEndpoint: string;
17+
sessionId: string;
18+
startTime: number;
19+
connectionId?: string;
20+
connectionStartTime?: number;
21+
};
22+
1523
export class BrowserSession extends DurableObject<Env> {
16-
endpoint?: string;
24+
sessionInfo?: SessionInfo;
1725
ws?: WebSocket;
1826
server?: WebSocket;
1927

2028
async fetch(_request: Request) {
2129
assert(
22-
this.endpoint !== undefined,
23-
"endpoint must be set before connecting"
30+
this.sessionInfo !== undefined,
31+
"sessionInfo must be set before connecting"
2432
);
2533

2634
// sometimes the websocket doesn't get the close event, so we need to close them explicitly if needed
2735
if (isClosed(this.ws) || isClosed(this.server)) {
28-
this.ws?.close();
29-
this.server?.close();
30-
this.ws = undefined;
31-
this.server = undefined;
36+
this.closeWebSockets();
3237
} else {
3338
assert.fail("WebSocket already initialized");
3439
}
@@ -38,7 +43,7 @@ export class BrowserSession extends DurableObject<Env> {
3843

3944
server.accept();
4045

41-
const wsEndpoint = this.endpoint.replace("ws://", "http://");
46+
const wsEndpoint = this.sessionInfo.wsEndpoint.replace("ws://", "http://");
4247

4348
const response = await fetch(wsEndpoint, {
4449
headers: {
@@ -85,37 +90,51 @@ export class BrowserSession extends DurableObject<Env> {
8590
});
8691
this.ws = ws;
8792
this.server = server;
93+
this.sessionInfo.connectionId = crypto.randomUUID();
94+
this.sessionInfo.connectionStartTime = Date.now();
8895

8996
return new Response(null, {
9097
status: 101,
9198
webSocket: client,
9299
});
93100
}
94-
async setEndpoint(endpoint: string) {
95-
this.endpoint = endpoint;
101+
102+
async setSessionInfo(sessionInfo: SessionInfo) {
103+
this.sessionInfo = sessionInfo;
104+
}
105+
106+
async getSessionInfo(): Promise<SessionInfo | undefined> {
107+
if (isClosed(this.ws) || isClosed(this.server)) {
108+
this.closeWebSockets();
109+
}
110+
return this.sessionInfo;
96111
}
97112

98113
async #checkStatus() {
99-
if (this.endpoint) {
114+
if (this.sessionInfo) {
100115
const url = new URL("http://example.com/browser/status");
101-
url.searchParams.set("wsEndpoint", this.endpoint);
116+
url.searchParams.set("sessionId", this.sessionInfo.sessionId);
102117
const resp = await this.env[CoreBindings.SERVICE_LOOPBACK].fetch(url);
103-
const { stopped } = resp.ok
104-
? ((await resp.json()) as { stopped: boolean })
105-
: {};
106118

107-
if (stopped) {
119+
if (!resp.ok) {
108120
// Browser process has exited, we should close the WebSocket
109121
// TODO should we send a error code?
110-
this.ws?.close();
111-
this.server?.close();
112-
this.ws = undefined;
113-
this.server = undefined;
114-
this.ctx.storage.deleteAll();
122+
this.closeWebSockets();
115123
return;
116124
}
117125
}
118126
}
127+
128+
closeWebSockets() {
129+
this.ws?.close();
130+
this.server?.close();
131+
this.ws = undefined;
132+
this.server = undefined;
133+
if (this.sessionInfo) {
134+
this.sessionInfo.connectionId = undefined;
135+
this.sessionInfo.connectionStartTime = undefined;
136+
}
137+
}
119138
}
120139

121140
export default {
@@ -126,18 +145,39 @@ export default {
126145
const resp = await env[CoreBindings.SERVICE_LOOPBACK].fetch(
127146
"http://example.com/browser/launch"
128147
);
129-
const wsEndpoint = await resp.text();
130-
const sessionId = crypto.randomUUID();
131-
const id = env.BrowserSession.idFromName(sessionId);
132-
await env.BrowserSession.get(id).setEndpoint(wsEndpoint);
133-
return Response.json({ sessionId });
148+
const sessionInfo: SessionInfo = await resp.json();
149+
const id = env.BrowserSession.idFromName(sessionInfo.sessionId);
150+
await env.BrowserSession.get(id).setSessionInfo(sessionInfo);
151+
return Response.json({ sessionId: sessionInfo.sessionId });
134152
}
135153
case "/v1/connectDevtools": {
136154
const sessionId = url.searchParams.get("browser_session");
137155
assert(sessionId !== null, "browser_session must be set");
138156
const id = env.BrowserSession.idFromName(sessionId);
139157
return env.BrowserSession.get(id).fetch(request);
140158
}
159+
case "/v1/sessions": {
160+
const sessionIds = (await env[CoreBindings.SERVICE_LOOPBACK]
161+
.fetch("http://example.com/browser/sessionIds")
162+
.then((resp) => resp.json())) as string[];
163+
const sessions = await Promise.all(
164+
sessionIds.map(async (sessionId) => {
165+
const id = env.BrowserSession.idFromName(sessionId);
166+
return env.BrowserSession.get(id)
167+
.getSessionInfo()
168+
.then((sessionInfo) => {
169+
if (!sessionInfo) return null;
170+
return {
171+
sessionId: sessionInfo.sessionId,
172+
startTime: sessionInfo.startTime,
173+
connectionId: sessionInfo.connectionId,
174+
connectionStartTime: sessionInfo.connectionStartTime,
175+
};
176+
});
177+
})
178+
).then((results) => results.filter(Boolean));
179+
return Response.json({ sessions });
180+
}
141181
default:
142182
return new Response("Not implemented", { status: 405 });
143183
}

0 commit comments

Comments
 (0)