Skip to content

Commit ddadb93

Browse files
chore: implement local browser rendering /v1/session endpoint (#10255)
* chore: implement local browser rendering /v1/session endpoint * Update .changeset/petite-paws-allow.md Co-authored-by: Carmen Popoviciu <[email protected]> --------- Co-authored-by: Carmen Popoviciu <[email protected]>
1 parent 9b09751 commit ddadb93

File tree

8 files changed

+258
-63
lines changed

8 files changed

+258
-63
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.sequential("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/src/index.ts

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -848,7 +848,7 @@ export class Miniflare {
848848
#workerOpts: PluginWorkerOptions[];
849849
#log: Log;
850850

851-
// key is the browser wsEndpoint, value is the browser process
851+
// key is the browser session ID, value is the browser process
852852
#browserProcesses: Map<string, Process> = new Map();
853853

854854
readonly #runtime?: Runtime;
@@ -1180,32 +1180,31 @@ export class Miniflare {
11801180
this.#log.logWithLevel(logLevel, message);
11811181
response = new Response(null, { status: 204 });
11821182
} else if (url.pathname === "/browser/launch") {
1183-
const { browserProcess, wsEndpoint } = await launchBrowser({
1184-
// Puppeteer v22.8.2 supported chrome version:
1185-
// https://pptr.dev/supported-browsers#supported-browser-version-list
1186-
//
1187-
// It should match the supported chrome version for the upstream puppeteer
1188-
// version from which @cloudflare/puppeteer branched off, which is specified in:
1189-
// https://github.com/cloudflare/puppeteer/tree/v1.0.2?tab=readme-ov-file#workers-version-of-puppeteer-core
1190-
browserVersion: "124.0.6367.207",
1191-
log: this.#log,
1192-
tmpPath: this.#tmpPath,
1193-
});
1183+
const { sessionId, browserProcess, startTime, wsEndpoint } =
1184+
await launchBrowser({
1185+
// Puppeteer v22.8.2 supported chrome version:
1186+
// https://pptr.dev/supported-browsers#supported-browser-version-list
1187+
//
1188+
// It should match the supported chrome version for the upstream puppeteer
1189+
// version from which @cloudflare/puppeteer branched off, which is specified in:
1190+
// https://github.com/cloudflare/puppeteer/tree/v1.0.2?tab=readme-ov-file#workers-version-of-puppeteer-core
1191+
browserVersion: "124.0.6367.207",
1192+
log: this.#log,
1193+
tmpPath: this.#tmpPath,
1194+
});
11941195
browserProcess.nodeProcess.on("exit", () => {
1195-
this.#browserProcesses.delete(wsEndpoint);
1196+
this.#browserProcesses.delete(sessionId);
11961197
});
1197-
this.#browserProcesses.set(wsEndpoint, browserProcess);
1198-
response = new Response(wsEndpoint);
1198+
this.#browserProcesses.set(sessionId, browserProcess);
1199+
response = Response.json({ wsEndpoint, sessionId, startTime });
11991200
} else if (url.pathname === "/browser/status") {
1200-
const wsEndpoint = url.searchParams.get("wsEndpoint");
1201-
assert(wsEndpoint !== null, "Missing wsEndpoint query parameter");
1202-
const process = this.#browserProcesses.get(wsEndpoint);
1203-
const status = {
1204-
stopped: !process,
1205-
};
1206-
response = new Response(JSON.stringify(status), {
1207-
headers: { "Content-Type": "application/json" },
1208-
});
1201+
const sessionId = url.searchParams.get("sessionId");
1202+
assert(sessionId !== null, "Missing sessionId query parameter");
1203+
const process = this.#browserProcesses.get(sessionId);
1204+
response = new Response(null, { status: process ? 200 : 410 });
1205+
} else if (url.pathname === "/browser/sessionIds") {
1206+
const sessionIds = this.#browserProcesses.keys();
1207+
response = Response.json(Array.from(sessionIds));
12091208
} else if (url.pathname === "/core/store-temp-file") {
12101209
const prefix = url.searchParams.get("prefix");
12111210
const folder = prefix ? `files/${prefix}` : "files";

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,5 +227,6 @@ export async function launchBrowser({
227227
const wsEndpoint = await browserProcess.waitForLineOutput(
228228
CDP_WEBSOCKET_ENDPOINT_REGEX
229229
);
230-
return { sessionId, browserProcess, wsEndpoint };
230+
const startTime = Date.now();
231+
return { sessionId, browserProcess, startTime, wsEndpoint };
231232
}

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)