Skip to content

Commit f3a1bd5

Browse files
committed
Fix service worker upstream routing
1 parent a3473f1 commit f3a1bd5

11 files changed

Lines changed: 256 additions & 16 deletions

File tree

go/modcdp/client/ModCDPClient.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,7 @@ func (c *ModCDPClient) connectUpstreamTransport() error {
707707
(c.Config.ServerConfig.Upstream.UpstreamWSCDPURL == "" ||
708708
c.Config.ServerConfig.Upstream.UpstreamWSCDPURL == initialCDPURL ||
709709
c.Config.ServerConfig.Upstream.UpstreamWSCDPURL == launchedCDPURL) {
710+
c.Config.ServerConfig.Upstream.UpstreamMode = "ws"
710711
c.Config.ServerConfig.Upstream.UpstreamWSCDPURL = loopbackCDPURL
711712
}
712713
}

go/modcdp/injector/extension.zip

-1.31 KB
Binary file not shown.

go/modcdp/launcher/BrowserLauncher.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ func (l BrowserLauncher) ConfigForServer(upstreamConfig UpstreamTransportConfig)
107107
launcherLocalLoopbackCDPURL = upstreamConfig.UpstreamWSCDPURL
108108
}
109109
if launcherLocalLoopbackCDPURL != "" {
110-
return map[string]any{"upstream": map[string]any{"upstream_ws_cdp_url": launcherLocalLoopbackCDPURL}}
110+
return map[string]any{"upstream": map[string]any{"upstream_mode": "ws", "upstream_ws_cdp_url": launcherLocalLoopbackCDPURL}}
111111
}
112112
return map[string]any{}
113113
}

js/src/client/ModCDPClient.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,28 @@ export class ModCDPClient<
258258
upstream_cdp_send_timeout_ms: this.config.client_cdp_send_timeout_ms,
259259
});
260260
if (upstream !== undefined) {
261-
this.upstream.update(upstream);
261+
const upstream_mode =
262+
typeof (upstream as { upstream_mode?: unknown }).upstream_mode === "string"
263+
? (upstream as { upstream_mode: string }).upstream_mode
264+
: this.upstream.config.upstream_mode;
265+
if (upstream_mode !== this.upstream.config.upstream_mode) {
266+
const Upstream = upstream_transport_constructors.get(upstream_mode);
267+
if (!Upstream) throw new Error(`unknown upstream_mode=${upstream_mode}`);
268+
const previous_upstream = this.upstream;
269+
this.upstream = new Upstream(upstream);
270+
this.upstream.update({
271+
upstream_cdp_send_timeout_ms: this.config.client_cdp_send_timeout_ms,
272+
});
273+
this.router.stop();
274+
this.router = new AutoSessionRouter({
275+
...this.router.config,
276+
upstream: this.upstream,
277+
types: this.types,
278+
});
279+
void previous_upstream.close();
280+
} else {
281+
this.upstream.update(upstream);
282+
}
262283
}
263284
if (router !== undefined) {
264285
this.router.config = ModCDPRouterConfigSchema.parse({

js/src/launcher/BrowserLauncher.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,20 @@ import type { z } from "zod";
99

1010
type LauncherConfig = z.input<typeof ModCDPLauncherConfigSchema>;
1111
type LauncherMode = ReturnType<typeof ModCDPLauncherConfigSchema.parse>["launcher_mode"];
12+
type LauncherUpstreamConfig = UpstreamTransportConfig & {
13+
upstream_pipe_read?: NodeJS.ReadableStream;
14+
upstream_pipe_write?: NodeJS.WritableStream;
15+
};
1216

1317
type LaunchedBrowser = {
1418
proc?: unknown;
1519
cdp_listen_port?: number;
20+
// Browser websocket CDP endpoint when one exists. Pipe transports expose pipe handles instead.
1621
cdp_url: string | null;
1722
// Extension-dialable loopback CDP endpoint when it differs from cdp_url (usually they are the same unless public-facing cdp url differs from intranet/localhost equivalent).
1823
loopback_cdp_url?: string | null;
24+
pipe_read?: NodeJS.ReadableStream | null;
25+
pipe_write?: NodeJS.WritableStream | null;
1926
profile_dir?: string | null;
2027
browserbase_session_id?: string | null;
2128
browserbase_session_url?: string | null;
@@ -78,9 +85,11 @@ class BrowserLauncher {
7885
}
7986

8087
configForUpstream(): UpstreamTransportConfig {
81-
const config: UpstreamTransportConfig = {};
88+
const config: LauncherUpstreamConfig = {};
8289
const upstream_ws_cdp_url = this.launched?.cdp_url ?? this.config.launcher_remote_cdp_url;
8390
if (upstream_ws_cdp_url) config.upstream_ws_cdp_url = upstream_ws_cdp_url;
91+
if (this.launched?.pipe_read) config.upstream_pipe_read = this.launched.pipe_read;
92+
if (this.launched?.pipe_write) config.upstream_pipe_write = this.launched.pipe_write;
8493
return config;
8594
}
8695

@@ -89,9 +98,11 @@ class BrowserLauncher {
8998
this.launched?.loopback_cdp_url ??
9099
(upstream.config.upstream_mode === "ws" && upstream.config.upstream_ws_cdp_url
91100
? upstream.config.upstream_ws_cdp_url
92-
: null);
101+
: upstream.config.upstream_mode !== "ws" && upstream.config.upstream_mode !== "pipe" && this.launched?.cdp_url
102+
? this.launched.cdp_url
103+
: null);
93104
return launcher_local_loopback_cdp_url
94-
? { upstream: { upstream_ws_cdp_url: launcher_local_loopback_cdp_url } }
105+
? { upstream: { upstream_mode: "ws", upstream_ws_cdp_url: launcher_local_loopback_cdp_url } }
95106
: {};
96107
}
97108

js/src/launcher/LocalBrowserLauncher.ts

Lines changed: 203 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,20 @@ import net from "node:net";
1010
import type { AddressInfo } from "node:net";
1111
import { homedir, platform, tmpdir } from "node:os";
1212
import path from "node:path";
13-
import { BrowserLauncher, type LauncherConfig, type LaunchedBrowser } from "./BrowserLauncher.js";
13+
import { z } from "zod";
14+
import {
15+
BrowserLauncher,
16+
resolveCdpWebSocketUrl,
17+
type LauncherConfig,
18+
type LaunchedBrowser,
19+
} from "./BrowserLauncher.js";
20+
import { ModCDPLauncherConfigSchema } from "../types/modcdp.js";
21+
22+
const LocalBrowserLauncherConfigSchema = ModCDPLauncherConfigSchema.extend({
23+
launcher_local_cdp_transport: z.enum(["ws", "pipe"]).default("ws"),
24+
});
25+
type LocalBrowserLauncherConfig = z.infer<typeof LocalBrowserLauncherConfigSchema>;
26+
type LocalBrowserLauncherInput = z.input<typeof LocalBrowserLauncherConfigSchema>;
1427

1528
function wildcardToRegExp(value: string) {
1629
return new RegExp(`^${value.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`);
@@ -145,6 +158,28 @@ function delay(ms: number) {
145158
return new Promise((resolve) => setTimeout(resolve, ms));
146159
}
147160

161+
function mergeLocalChromeArgs(existing: string[] = [], incoming: string[] = []) {
162+
const args = [...existing, ...incoming];
163+
const load_extension_paths: string[] = [];
164+
const merged: string[] = [];
165+
for (const arg of args) {
166+
if (!arg.startsWith("--load-extension=")) {
167+
merged.push(arg);
168+
continue;
169+
}
170+
for (const extension_path of arg.slice("--load-extension=".length).split(",")) {
171+
if (extension_path && !load_extension_paths.includes(extension_path)) load_extension_paths.push(extension_path);
172+
}
173+
}
174+
if (load_extension_paths.length > 0) {
175+
const first_url_index = merged.findIndex((arg) => !arg.startsWith("-"));
176+
const load_extension_arg = `--load-extension=${load_extension_paths.join(",")}`;
177+
if (first_url_index === -1) merged.push(load_extension_arg);
178+
else merged.splice(first_url_index, 0, load_extension_arg);
179+
}
180+
return merged;
181+
}
182+
148183
async function terminateProcess(proc: ChildProcess, timeoutMs = 2_000) {
149184
if (proc.exitCode !== null || proc.signalCode !== null) return;
150185
const signalProcess = (signal: NodeJS.Signals) => {
@@ -176,6 +211,98 @@ async function removeProfileDir(profile_dir: string) {
176211
} catch {}
177212
}
178213

214+
async function waitForPipeReady(
215+
pipe_read: NodeJS.ReadableStream,
216+
pipe_write: NodeJS.WritableStream,
217+
timeoutMs: number,
218+
) {
219+
let buffer = "";
220+
const readyId = 1;
221+
await new Promise<void>((resolve, reject) => {
222+
const cleanup = () => {
223+
clearTimeout(timeout);
224+
pipe_read.off("data", onData);
225+
pipe_read.off("error", onError);
226+
pipe_write.off("error", onError);
227+
};
228+
const onError = (error: Error) => {
229+
cleanup();
230+
reject(error);
231+
};
232+
const onData = (chunk: Buffer | string) => {
233+
buffer += chunk.toString();
234+
while (buffer.includes("\0")) {
235+
const [raw, ...rest] = buffer.split("\0");
236+
buffer = rest.join("\0");
237+
if (!raw) continue;
238+
const message = JSON.parse(raw);
239+
if (message.id !== readyId) continue;
240+
cleanup();
241+
if (message.error) reject(new Error(message.error.message ?? "Browser.getVersion failed over pipe"));
242+
else resolve();
243+
}
244+
};
245+
const timeout = setTimeout(() => {
246+
cleanup();
247+
reject(new Error(`Chrome remote-debugging pipe did not respond within ${timeoutMs}ms`));
248+
}, timeoutMs);
249+
pipe_read.on("data", onData);
250+
pipe_read.on("error", onError);
251+
pipe_write.on("error", onError);
252+
pipe_write.write(`${JSON.stringify({ id: readyId, method: "Browser.getVersion" })}\0`);
253+
});
254+
}
255+
256+
async function waitForCdpWebSocketUrl(cdp_url: string, timeout_ms: number, poll_interval_ms: number) {
257+
const deadline = Date.now() + timeout_ms;
258+
let lastError: unknown = null;
259+
while (Date.now() < deadline) {
260+
try {
261+
return await resolveCdpWebSocketUrl(cdp_url);
262+
} catch (error) {
263+
lastError = error;
264+
await delay(poll_interval_ms);
265+
}
266+
}
267+
if (lastError instanceof Error) {
268+
throw new Error(
269+
`Chrome at ${cdp_url} did not expose a WebSocket CDP URL within ${timeout_ms}ms: ${lastError.message}`,
270+
);
271+
}
272+
throw new Error(`Chrome at ${cdp_url} did not expose a WebSocket CDP URL within ${timeout_ms}ms`);
273+
}
274+
275+
async function waitForBrowserSelectedCdpWebSocketUrl(
276+
profile_dir: string,
277+
timeout_ms: number,
278+
poll_interval_ms: number,
279+
assertChromeRunning: () => void,
280+
) {
281+
const deadline = Date.now() + timeout_ms;
282+
let lastError: unknown = null;
283+
while (Date.now() < deadline) {
284+
assertChromeRunning();
285+
const activePort = await readDevToolsActivePort(profile_dir);
286+
if (activePort) {
287+
try {
288+
return {
289+
cdp_listen_port: activePort.cdp_listen_port,
290+
cdp_url: await resolveCdpWebSocketUrl(activePort.cdp_url),
291+
};
292+
} catch (error) {
293+
lastError = error;
294+
}
295+
}
296+
await delay(poll_interval_ms);
297+
}
298+
if (lastError instanceof Error) {
299+
throw new Error(
300+
`Chrome did not expose DevToolsActivePort from ${profile_dir} within ${timeout_ms}ms: ${lastError.message}`,
301+
);
302+
}
303+
throw new Error(`Chrome did not expose DevToolsActivePort from ${profile_dir} within ${timeout_ms}ms`);
304+
}
305+
179306
async function readDevToolsActivePort(profile_dir: string) {
180307
const activePortPath = path.join(profile_dir, "DevToolsActivePort");
181308
let body: string;
@@ -193,8 +320,27 @@ async function readDevToolsActivePort(profile_dir: string) {
193320
}
194321

195322
class LocalBrowserLauncher extends BrowserLauncher {
196-
constructor(config: LauncherConfig = {}) {
197-
super({ ...config, launcher_mode: "local" });
323+
declare config: LocalBrowserLauncherConfig;
324+
325+
constructor(config: LocalBrowserLauncherInput = {}) {
326+
const { launcher_local_cdp_transport: _launcher_local_cdp_transport, ...base_config } = config;
327+
super({ ...base_config, launcher_mode: "local" } as LauncherConfig);
328+
this.config = LocalBrowserLauncherConfigSchema.parse({ ...config, launcher_mode: "local" });
329+
}
330+
331+
override update(config: LocalBrowserLauncherInput = {}) {
332+
const next_config = LocalBrowserLauncherConfigSchema.parse({ ...this.config, ...config, launcher_mode: "local" });
333+
if (config.launcher_local_args) {
334+
next_config.launcher_local_args = mergeLocalChromeArgs(this.config.launcher_local_args, config.launcher_local_args);
335+
}
336+
if (config.launcher_local_extra_args) {
337+
next_config.launcher_local_extra_args = mergeLocalChromeArgs(
338+
this.config.launcher_local_extra_args,
339+
config.launcher_local_extra_args,
340+
);
341+
}
342+
this.config = next_config;
343+
return this;
198344
}
199345

200346
static findChromeBinary(explicit?: string | null) {
@@ -219,9 +365,12 @@ class LocalBrowserLauncher extends BrowserLauncher {
219365
}
220366

221367
async launch(config: LauncherConfig = {}): Promise<LaunchedBrowser> {
222-
const launch_config = { ...this.config, ...config };
368+
const launch_config = LocalBrowserLauncherConfigSchema.parse({ ...this.config, ...config, launcher_mode: "local" });
223369
const exe = LocalBrowserLauncher.findChromeBinary(launch_config.launcher_local_executable_path);
224-
const usePort = launch_config.launcher_local_cdp_listen_port ?? 0;
370+
const usePipe = launch_config.launcher_local_cdp_transport === "pipe";
371+
const useLoopbackCdp =
372+
!usePipe || launch_config.launcher_local_loopback_cdp || launch_config.launcher_local_cdp_listen_port != null;
373+
const usePort = useLoopbackCdp ? (launch_config.launcher_local_cdp_listen_port ?? 0) : null;
225374
const profile_dir = launch_config.launcher_local_user_data_dir || (await mkdtemp(path.join(tmpdir(), "modcdp.")));
226375
const default_headless = process.platform === "linux" && !process.env.DISPLAY;
227376
const headless = launch_config.launcher_local_headless ?? default_headless;
@@ -232,15 +381,21 @@ class LocalBrowserLauncher extends BrowserLauncher {
232381
"--disable-gpu",
233382
sandbox === false ? "--no-sandbox" : null,
234383
`--user-data-dir=${profile_dir}`,
235-
"--remote-debugging-address=127.0.0.1",
236-
`--remote-debugging-port=${usePort}`,
384+
useLoopbackCdp ? "--remote-debugging-address=127.0.0.1" : null,
385+
useLoopbackCdp ? `--remote-debugging-port=${usePort}` : null,
386+
usePipe ? "--remote-debugging-pipe" : null,
237387
...launch_config.launcher_local_args,
238388
...launch_config.launcher_local_extra_args,
239389
"about:blank",
240390
].filter(Boolean);
241391

392+
const stdio = (usePipe ? ["ignore", "ignore", "ignore", "pipe", "pipe"] : "ignore") as
393+
| "ignore"
394+
| "inherit"
395+
| "pipe"
396+
| import("node:child_process").StdioOptions;
242397
const proc = spawn(exe, flags, {
243-
stdio: "ignore",
398+
stdio,
244399
detached: process.platform !== "win32",
245400
});
246401
let spawnError: Error | null = null;
@@ -262,6 +417,46 @@ class LocalBrowserLauncher extends BrowserLauncher {
262417
}
263418
};
264419

420+
if (usePipe) {
421+
const pipe_write = proc.stdio[3] as NodeJS.WritableStream | null;
422+
const pipe_read = proc.stdio[4] as NodeJS.ReadableStream | null;
423+
if (!pipe_write || !pipe_read) {
424+
await close();
425+
throw new Error("Chrome remote-debugging pipe stdio handles were not created.");
426+
}
427+
assertChromeRunning();
428+
await waitForPipeReady(pipe_read, pipe_write, launch_config.launcher_local_chrome_ready_timeout_ms);
429+
const loopback =
430+
usePort == null
431+
? null
432+
: usePort === 0
433+
? await waitForBrowserSelectedCdpWebSocketUrl(
434+
profile_dir,
435+
launch_config.launcher_local_chrome_ready_timeout_ms,
436+
launch_config.launcher_local_chrome_ready_poll_interval_ms,
437+
assertChromeRunning,
438+
)
439+
: {
440+
cdp_listen_port: usePort,
441+
cdp_url: await waitForCdpWebSocketUrl(
442+
`http://127.0.0.1:${usePort}`,
443+
launch_config.launcher_local_chrome_ready_timeout_ms,
444+
launch_config.launcher_local_chrome_ready_poll_interval_ms,
445+
),
446+
};
447+
this.launched = {
448+
proc,
449+
...(loopback == null ? {} : { cdp_listen_port: loopback.cdp_listen_port }),
450+
cdp_url: null,
451+
...(loopback == null ? {} : { loopback_cdp_url: loopback.cdp_url }),
452+
pipe_read,
453+
pipe_write,
454+
profile_dir,
455+
close,
456+
};
457+
return this.launched;
458+
}
459+
265460
const deadline = Date.now() + launch_config.launcher_local_chrome_ready_timeout_ms;
266461
while (Date.now() < deadline) {
267462
try {

js/src/server/ModCDPServer.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
import * as Browser from "../types/generated/zod/Browser.js";
99
import * as Runtime from "../types/generated/zod/Runtime.js";
1010
import type { z } from "zod";
11-
import { ModCDPClient } from "../client/ModCDPClient.js";
11+
import { ModCDPClient, upstream_transport_constructors } from "../client/ModCDPClient.js";
1212
import { ModCDPConfigureParamsSchema, ModCDPServerConfigSchema } from "../types/modcdp.js";
1313
import { routeFor } from "../translate/translate.js";
14+
import { ChromeDebuggerUpstreamTransport } from "../transport/ChromeDebuggerUpstreamTransport.js";
1415
import { DownstreamTransportSet } from "../transport/DownstreamTransportSet.js";
1516
import { NativeMessagingDownstreamTransport } from "../transport/NativeMessagingDownstreamTransport.js";
1617
import { NATSDownstreamTransport } from "../transport/NATSDownstreamTransport.js";
@@ -45,6 +46,8 @@ const DEFAULT_ROUTES = {
4546
const OFFSCREEN_KEEP_ALIVE_PORT_NAME = "ModCDPOffscreenKeepAlive";
4647
const OFFSCREEN_KEEP_ALIVE_PATH = "offscreen/keepalive.html";
4748

49+
upstream_transport_constructors.set("chromedebugger", ChromeDebuggerUpstreamTransport);
50+
4851
/**
4952
* Extension-side ModCDP server.
5053
*

js/src/transport/ChromeDebuggerUpstreamTransport.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,15 @@ class ChromeDebuggerUpstreamTransport extends UpstreamTransport {
7575
this.config = ChromeDebuggerUpstreamTransportConfigSchema.parse({ ...config, upstream_mode: "chromedebugger" });
7676
}
7777

78+
override update(config: z.input<typeof ChromeDebuggerUpstreamTransportConfigSchema> = {}) {
79+
this.config = ChromeDebuggerUpstreamTransportConfigSchema.parse({
80+
...this.config,
81+
...config,
82+
upstream_mode: "chromedebugger",
83+
});
84+
return this;
85+
}
86+
7887
/** Install chrome.debugger listeners for this service-worker lifetime. */
7988
override async connect() {
8089
this.installEventListener();

0 commit comments

Comments
 (0)