Connection.ts (5642B)
1 /** 2 * @license 3 * Copyright 2017 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 7 import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; 8 9 import {CallbackRegistry} from '../common/CallbackRegistry.js'; 10 import type {ConnectionTransport} from '../common/ConnectionTransport.js'; 11 import {debug} from '../common/Debug.js'; 12 import type {EventsWithWildcard} from '../common/EventEmitter.js'; 13 import {EventEmitter} from '../common/EventEmitter.js'; 14 import {debugError} from '../common/util.js'; 15 import {assert} from '../util/assert.js'; 16 17 import {BidiCdpSession} from './CDPSession.js'; 18 import type { 19 BidiEvents, 20 Commands as BidiCommands, 21 Connection, 22 } from './core/Connection.js'; 23 24 const debugProtocolSend = debug('puppeteer:webDriverBiDi:SEND ►'); 25 const debugProtocolReceive = debug('puppeteer:webDriverBiDi:RECV ◀'); 26 27 /** 28 * @internal 29 */ 30 export interface Commands extends BidiCommands { 31 'goog:cdp.sendCommand': { 32 params: Bidi.Cdp.SendCommandParameters; 33 returnType: Bidi.Cdp.SendCommandResult; 34 }; 35 'goog:cdp.getSession': { 36 params: Bidi.Cdp.GetSessionParameters; 37 returnType: Bidi.Cdp.GetSessionResult; 38 }; 39 'goog:cdp.resolveRealm': { 40 params: Bidi.Cdp.ResolveRealmParameters; 41 returnType: Bidi.Cdp.ResolveRealmResult; 42 }; 43 } 44 45 /** 46 * @internal 47 */ 48 export class BidiConnection 49 extends EventEmitter<BidiEvents> 50 implements Connection 51 { 52 #url: string; 53 #transport: ConnectionTransport; 54 #delay: number; 55 #timeout = 0; 56 #closed = false; 57 #callbacks = new CallbackRegistry(); 58 #emitters: Array<EventEmitter<any>> = []; 59 60 constructor( 61 url: string, 62 transport: ConnectionTransport, 63 delay = 0, 64 timeout?: number, 65 ) { 66 super(); 67 this.#url = url; 68 this.#delay = delay; 69 this.#timeout = timeout ?? 180_000; 70 71 this.#transport = transport; 72 this.#transport.onmessage = this.onMessage.bind(this); 73 this.#transport.onclose = this.unbind.bind(this); 74 } 75 76 get closed(): boolean { 77 return this.#closed; 78 } 79 80 get url(): string { 81 return this.#url; 82 } 83 84 pipeTo<Events extends BidiEvents>(emitter: EventEmitter<Events>): void { 85 this.#emitters.push(emitter); 86 } 87 88 override emit<Key extends keyof EventsWithWildcard<BidiEvents>>( 89 type: Key, 90 event: EventsWithWildcard<BidiEvents>[Key], 91 ): boolean { 92 for (const emitter of this.#emitters) { 93 emitter.emit(type, event); 94 } 95 return super.emit(type, event); 96 } 97 98 send<T extends keyof Commands>( 99 method: T, 100 params: Commands[T]['params'], 101 timeout?: number, 102 ): Promise<{result: Commands[T]['returnType']}> { 103 assert(!this.#closed, 'Protocol error: Connection closed.'); 104 105 return this.#callbacks.create(method, timeout ?? this.#timeout, id => { 106 const stringifiedMessage = JSON.stringify({ 107 id, 108 method, 109 params, 110 } as Bidi.Command); 111 debugProtocolSend(stringifiedMessage); 112 this.#transport.send(stringifiedMessage); 113 }) as Promise<{result: Commands[T]['returnType']}>; 114 } 115 116 /** 117 * @internal 118 */ 119 protected async onMessage(message: string): Promise<void> { 120 if (this.#delay) { 121 await new Promise(f => { 122 return setTimeout(f, this.#delay); 123 }); 124 } 125 debugProtocolReceive(message); 126 const object: Bidi.ChromiumBidi.Message = JSON.parse(message); 127 if ('type' in object) { 128 switch (object.type) { 129 case 'success': 130 this.#callbacks.resolve(object.id, object); 131 return; 132 case 'error': 133 if (object.id === null) { 134 break; 135 } 136 this.#callbacks.reject( 137 object.id, 138 createProtocolError(object), 139 `${object.error}: ${object.message}`, 140 ); 141 return; 142 case 'event': 143 if (isCdpEvent(object)) { 144 BidiCdpSession.sessions 145 .get(object.params.session) 146 ?.emit(object.params.event, object.params.params); 147 return; 148 } 149 // SAFETY: We know the method and parameter still match here. 150 this.emit( 151 object.method, 152 object.params as BidiEvents[keyof BidiEvents], 153 ); 154 return; 155 } 156 } 157 // Even if the response in not in BiDi protocol format but `id` is provided, reject 158 // the callback. This can happen if the endpoint supports CDP instead of BiDi. 159 if ('id' in object) { 160 this.#callbacks.reject( 161 (object as {id: number}).id, 162 `Protocol Error. Message is not in BiDi protocol format: '${message}'`, 163 object.message, 164 ); 165 } 166 debugError(object); 167 } 168 169 /** 170 * Unbinds the connection, but keeps the transport open. Useful when the transport will 171 * be reused by other connection e.g. with different protocol. 172 * @internal 173 */ 174 unbind(): void { 175 if (this.#closed) { 176 return; 177 } 178 this.#closed = true; 179 // Both may still be invoked and produce errors 180 this.#transport.onmessage = () => {}; 181 this.#transport.onclose = () => {}; 182 183 this.#callbacks.clear(); 184 } 185 186 /** 187 * Unbinds the connection and closes the transport. 188 */ 189 dispose(): void { 190 this.unbind(); 191 this.#transport.close(); 192 } 193 194 getPendingProtocolErrors(): Error[] { 195 return this.#callbacks.getPendingProtocolErrors(); 196 } 197 } 198 199 /** 200 * @internal 201 */ 202 function createProtocolError(object: Bidi.ErrorResponse): string { 203 let message = `${object.error} ${object.message}`; 204 if (object.stacktrace) { 205 message += ` ${object.stacktrace}`; 206 } 207 return message; 208 } 209 210 function isCdpEvent(event: Bidi.ChromiumBidi.Event): event is Bidi.Cdp.Event { 211 return event.method.startsWith('goog:cdp.'); 212 }