99import { bindDomains , type Domains , type Transport } from './generated.ts' ;
1010
1111type Pending = {
12+ ws : WebSocket ;
1213 resolve : ( v : unknown ) => void ;
1314 reject : ( e : unknown ) => void ;
1415} ;
@@ -46,6 +47,7 @@ export class Session implements Transport {
4647 private nextId = 1 ;
4748 private pending = new Map < number , Pending > ( ) ;
4849 private activeSessionId : string | undefined ;
50+ private reattachPromise ?: Promise < void > ;
4951 private eventListeners : Array < ( method : string , params : unknown , sessionId ?: string ) => void > = [ ] ;
5052 private callResultListeners : Array < ( method : string , params : unknown , result : unknown ) => void > = [ ] ;
5153
@@ -124,15 +126,29 @@ export class Session implements Transport {
124126 else res ( ) ;
125127 } ;
126128 const timer = setTimeout ( ( ) => finish ( new Error ( `timed out after ${ timeoutMs } ms` ) ) , timeoutMs ) ;
127- ws . addEventListener ( 'open' , ( ) => finish ( ) ) ;
129+ ws . addEventListener ( 'open' , ( ) => {
130+ if ( done ) {
131+ try { ws . close ( ) ; } catch { /* ignore */ }
132+ return ;
133+ }
134+ const previous = this . ws ;
135+ this . ws = ws ;
136+ this . activeSessionId = undefined ;
137+ finish ( ) ;
138+ if ( previous && previous !== ws ) {
139+ try { previous . close ( ) ; } catch { /* ignore */ }
140+ }
141+ } ) ;
128142 ws . addEventListener ( 'error' , ( e ) => finish ( new Error ( `WS error: ${ ( e as any ) ?. message ?? 'connect failed (likely 403, permission not granted, or port closed)' } ` ) ) ) ;
129- ws . addEventListener ( 'message' , ( e ) => this . onMessage ( String ( e . data ) ) ) ;
143+ ws . addEventListener ( 'message' , ( e ) => this . onMessage ( String ( e . data ) , ws ) ) ;
130144 ws . addEventListener ( 'close' , ( ) => {
131- for ( const [ , p ] of this . pending ) p . reject ( new Error ( 'CDP socket closed' ) ) ;
132- this . pending . clear ( ) ;
145+ this . rejectPending ( ws , new Error ( 'CDP socket closed' ) ) ;
146+ if ( this . ws === ws ) {
147+ this . ws = undefined ;
148+ this . activeSessionId = undefined ;
149+ }
133150 finish ( new Error ( 'WS closed before open (likely 403 or port closed)' ) ) ;
134151 } ) ;
135- this . ws = ws ;
136152 } ) ;
137153 }
138154
@@ -206,17 +222,36 @@ export class Session implements Transport {
206222 }
207223
208224 // Transport implementation. Called by the generated domain bindings.
209- _call ( method : string , params : unknown = { } ) : Promise < unknown > {
210- if ( ! this . ws || this . ws . readyState !== WebSocket . OPEN ) {
225+ async _call ( method : string , params : unknown = { } ) : Promise < unknown > {
226+ const browserLevel = isBrowserLevel ( method ) ;
227+ const sentSessionId = browserLevel ? undefined : this . activeSessionId ;
228+ try {
229+ return await this . send ( method , params , sentSessionId ) ;
230+ } catch ( error ) {
231+ if ( ! sentSessionId || ! isMissingSessionError ( error ) ) throw error ;
232+
233+ // Chrome explicitly rejected the command before executing it, so this is
234+ // safe to retry once. Socket drops are deliberately not retried: Chrome
235+ // may have applied a click or submission before the response was lost.
236+ if ( this . activeSessionId === sentSessionId ) this . activeSessionId = undefined ;
237+ if ( ! this . activeSessionId ) await this . reattachFirstPage ( ) ;
238+ if ( ! this . activeSessionId ) throw error ;
239+ return this . send ( method , params , this . activeSessionId ) ;
240+ }
241+ }
242+
243+ private send ( method : string , params : unknown , sessionId ?: string ) : Promise < unknown > {
244+ const ws = this . ws ;
245+ if ( ! ws || ws . readyState !== WebSocket . OPEN ) {
211246 return Promise . reject ( new Error ( 'Not connected. Call session.connect(...) first.' ) ) ;
212247 }
248+
213249 const id = this . nextId ++ ;
214250 const msg : Record < string , unknown > = { id, method, params : params ?? { } } ;
215- if ( this . activeSessionId && ! isBrowserLevel ( method ) ) {
216- msg . sessionId = this . activeSessionId ;
217- }
251+ if ( sessionId ) msg . sessionId = sessionId ;
218252 return new Promise ( ( resolve , reject ) => {
219253 this . pending . set ( id , {
254+ ws,
220255 resolve : ( v ) => {
221256 for ( const fn of this . callResultListeners ) {
222257 try { fn ( method , params , v ) ; } catch { /* ignore */ }
@@ -225,16 +260,50 @@ export class Session implements Transport {
225260 } ,
226261 reject,
227262 } ) ;
228- this . ws ! . send ( JSON . stringify ( msg ) ) ;
263+ try {
264+ ws . send ( JSON . stringify ( msg ) ) ;
265+ } catch ( error ) {
266+ this . pending . delete ( id ) ;
267+ reject ( error ) ;
268+ }
229269 } ) ;
230270 }
231271
232- private onMessage ( raw : string ) : void {
272+ private async reattachFirstPage ( ) : Promise < void > {
273+ if ( this . reattachPromise ) return this . reattachPromise ;
274+
275+ const attempt = this . attachFirstPage ( ) ;
276+ this . reattachPromise = attempt ;
277+ try {
278+ await attempt ;
279+ } finally {
280+ if ( this . reattachPromise === attempt ) this . reattachPromise = undefined ;
281+ }
282+ }
283+
284+ private async attachFirstPage ( ) : Promise < void > {
285+ const { targetInfos } = await this . domains . Target . getTargets ( { } ) ;
286+ const pages = targetInfos as PageTarget [ ] ;
287+ const targetId = pages . find ( isUsablePageTarget ) ?. targetId
288+ ?? ( await this . domains . Target . createTarget ( { url : 'about:blank' } ) ) . targetId ;
289+ await this . use ( targetId ) ;
290+ }
291+
292+ private rejectPending ( ws : WebSocket , error : Error ) : void {
293+ for ( const [ id , pending ] of this . pending ) {
294+ if ( pending . ws !== ws ) continue ;
295+ this . pending . delete ( id ) ;
296+ pending . reject ( error ) ;
297+ }
298+ }
299+
300+ private onMessage ( raw : string , ws : WebSocket ) : void {
301+ if ( ws !== this . ws ) return ;
233302 let m : any ;
234303 try { m = JSON . parse ( raw ) ; } catch { return ; }
235304 if ( typeof m . id === 'number' ) {
236305 const p = this . pending . get ( m . id ) ;
237- if ( ! p ) return ;
306+ if ( ! p || p . ws !== ws ) return ;
238307 this . pending . delete ( m . id ) ;
239308 if ( m . error ) p . reject ( new CdpError ( m . error . code , m . error . message , m . error . data ) ) ;
240309 else p . resolve ( m . result ) ;
@@ -258,6 +327,12 @@ function isBrowserLevel(method: string): boolean {
258327 return method . startsWith ( 'Browser.' ) || method . startsWith ( 'Target.' ) ;
259328}
260329
330+ function isMissingSessionError ( error : unknown ) : boolean {
331+ return error instanceof CdpError
332+ && error . code === - 32001
333+ && error . message . includes ( 'Session with given id not found' ) ;
334+ }
335+
261336/**
262337 * Resolve a WebSocket URL for one of the explicit connect forms:
263338 * { wsUrl } — passthrough.
@@ -329,6 +404,15 @@ export async function listPageTargets(session: Session): Promise<PageTarget[]> {
329404 ) ;
330405}
331406
407+ function isUsablePageTarget ( target : PageTarget ) : boolean {
408+ return target . type === 'page'
409+ && ! target . url . startsWith ( 'chrome://' )
410+ && ! target . url . startsWith ( 'chrome-untrusted://' )
411+ && ! target . url . startsWith ( 'devtools://' )
412+ && ! target . url . startsWith ( 'chrome-extension://' )
413+ && ! target . url . startsWith ( 'about:' ) ;
414+ }
415+
332416/**
333417 * Scan OS-specific user-data directories for Chromium-based browsers that
334418 * currently have remote debugging enabled (a `DevToolsActivePort` file exists
@@ -423,4 +507,3 @@ async function tryReadDevToolsActivePort(
423507 return undefined ;
424508 }
425509}
426-
0 commit comments