@@ -10,7 +10,20 @@ import net from "node:net";
1010import type { AddressInfo } from "node:net" ;
1111import { homedir , platform , tmpdir } from "node:os" ;
1212import 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
1528function 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+
148183async 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+
179306async 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
195322class 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 {
0 commit comments