-
Notifications
You must be signed in to change notification settings - Fork 3.3k
/
Copy pathcdp-connection.ts
236 lines (189 loc) · 8.36 KB
/
cdp-connection.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping'
import Debug from 'debug'
import EventEmitter from 'events'
import CDP from 'chrome-remote-interface'
import type { CypressError } from '@packages/errors'
import { debugCdpConnection, DebuggableCDPClient } from './debug-cdp-connection'
import type { CdpEvent, CdpCommand } from './cdp_automation'
import { CDPDisconnectedError, CDPTerminatedError, CDPAlreadyConnectedError } from './cri-errors'
import { asyncRetry } from '../util/async_retry'
import * as errors from '../errors'
import type WebSocket from 'ws'
const verboseDebugNs = 'cypress-verbose:server:browsers:cdp-connection'
export type CDPListener<T extends keyof ProtocolMapping.Events> = (params: ProtocolMapping.Events[T][0], sessionId?: string) => void
// CDPClient extends EventEmitter, but does not export that type information from its
// definitelytyped module
export type CdpClient = Exclude<EventEmitter, CDP.Client> & CDP.Client
/**
* There are three error messages we can encounter which should not be re-thrown, but
* should trigger a reconnection attempt if one is not in progress, and enqueue the
* command that errored. This regex is used in client.send to check for:
* - WebSocket connection closed
* - WebSocket not open
* - WebSocket already in CLOSING or CLOSED state
*/
const isWebsocketClosedErrorMessage = (message: string) => {
return /^WebSocket (?:connection closed|is (?:not open|already in CLOSING or CLOSED state))/.test(message)
}
export type CDPConnectionOptions = {
automaticallyReconnect: boolean
}
type CDPConnectionEventListeners = {
'cdp-connection-reconnect-error': (err: CypressError) => void
'cdp-connection-reconnect': () => void
'cdp-connection-closed': () => void
'cdp-connection-reconnect-attempt': (attemptNumber: number) => void
}
export type CDPConnectionEvent = keyof CDPConnectionEventListeners
type CDPConnectionEventListener<T extends CDPConnectionEvent> = CDPConnectionEventListeners[T]
export class CDPConnection {
private _emitter: EventEmitter = new EventEmitter()
private _connection: CdpClient | undefined
private _autoReconnect: boolean
private _terminated: boolean = false
private _reconnection: Promise<void> | undefined
private debug: Debug.Debugger
private verboseDebug: Debug.Debugger
constructor (private readonly _options: CDP.Options, connectionOptions: CDPConnectionOptions) {
this._autoReconnect = connectionOptions.automaticallyReconnect
this.debug = Debug(`cypress:server:browsers:cdp-connection:${_options.target}`)
this.verboseDebug = Debug(`${verboseDebugNs}:${_options.target}`)
}
get terminated () {
return this._terminated
}
get ws () {
// this is reached into by browser-cri-client to detect close events - needs rethinking
return (this._connection as { _ws?: WebSocket })._ws
}
on<T extends CdpEvent> (event: T, callback: CDPListener<T>) {
this.debug('attaching event listener to cdp connection', event)
this._emitter.on(event, callback)
}
addConnectionEventListener<T extends CDPConnectionEvent> (event: T, callback: CDPConnectionEventListener<T>) {
this.debug('adding connection event listener for ', event)
this._emitter.on(event, callback)
}
off<T extends CdpEvent> (event: T, callback: CDPListener<T>) {
this._emitter.off(event, callback)
}
removeConnectionEventListener<T extends CDPConnectionEvent> (event: T, callback: CDPConnectionEventListener<T>) {
this._emitter.off(event, callback)
}
async connect (): Promise<void> {
if (this._terminated) {
throw new CDPTerminatedError(`Cannot connect to CDP. Client target ${this._options.target} has been terminated.`)
}
if (this._connection) {
throw new CDPAlreadyConnectedError(`Cannot connect to CDP. Client target ${this._options.target} is already connected. Did you disconnect first?`)
}
this._connection = await CDP(this._options) as CdpClient
debugCdpConnection(this.verboseDebug.namespace, this._connection as DebuggableCDPClient)
this._connection.on('event', this._broadcastEvent)
if (this._autoReconnect) {
this._connection.on('disconnect', this._reconnect)
}
}
async disconnect () {
this.debug('disconnect of target %s requested.', this._options.target, { terminated: this._terminated, connection: !!this._connection, reconnection: !!this._reconnection })
if (this._terminated && !this._connection) {
return
}
this._terminated = true
if (this._connection) {
await this._gracefullyDisconnect()
this._emitter.emit('cdp-connection-closed')
}
}
private _gracefullyDisconnect = async () => {
this._connection?.off('event', this._broadcastEvent)
this._connection?.off('disconnect', this._reconnect)
await this._connection?.close()
this._connection = undefined
}
async send<T extends CdpCommand> (
command: T,
data?: ProtocolMapping.Commands[T]['paramsType'][0],
sessionId?: string,
): Promise<ProtocolMapping.Commands[T]['returnType']> {
if (this.terminated) {
throw new CDPDisconnectedError(`${command} will not run as the CRI connection to Target ${this._options.target} has been terminated.`)
}
if (!this._connection) {
throw new CDPDisconnectedError(`${command} will not run as the CRI connection to Target ${this._options.target} has not been established.`)
}
try {
return await this._connection.send(command, data, sessionId)
} catch (e) {
// Clients may wish to determine if the command should be enqueued
// should enqueue logic live in this class tho??
if (isWebsocketClosedErrorMessage(e.message)) {
throw new CDPDisconnectedError(`${command} failed due to the websocket being disconnected.`, e)
}
throw e
}
}
private _reconnect = async () => {
this.debug('Reconnection requested')
if (this._terminated) {
return
}
if (this._reconnection) {
return this._reconnection
}
if (this._connection) {
try {
await this._gracefullyDisconnect()
} catch (e) {
this.debug('Error cleaning up existing CDP connection before creating a new connection: ', e)
} finally {
this._connection = undefined
}
}
let attempt = 0
this._reconnection = asyncRetry(async () => {
attempt++
this.debug('Reconnection attempt %d for Target %s', attempt, this._options.target)
if (this._terminated) {
this.debug('Not reconnecting, connection to %s has been terminated', this._options.target)
throw new CDPTerminatedError(`Cannot reconnect to CDP. Client target ${this._options.target} has been terminated.`)
}
this._emitter.emit('cdp-connection-reconnect-attempt', attempt)
await this.connect()
}, {
maxAttempts: 20,
retryDelay: () => {
return 100
},
shouldRetry (err) {
return !(err && CDPTerminatedError.isCDPTerminatedError(err))
},
})()
try {
await this._reconnection
this._emitter.emit('cdp-connection-reconnect')
} catch (err) {
this.debug('error(s) on reconnecting: ', err)
const significantError: Error = err.errors ? (err as AggregateError).errors[err.errors.length - 1] : err
const retryHaltedDueToClosed = CDPTerminatedError.isCDPTerminatedError(err) ||
(err as AggregateError)?.errors?.find((predicate) => CDPTerminatedError.isCDPTerminatedError(predicate))
// if .disconnect() was called while trying to reconnect, there will be no active connection
// so the .disconnect() method will not emit the connection closed event. However, we do
// want to emit that once the reconnection attempts cease due to being closed.
if (retryHaltedDueToClosed) {
this._emitter.emit('cdp-connection-closed')
} else {
const cdpError = errors.get('CDP_COULD_NOT_RECONNECT', significantError)
cdpError.isFatalApiErr = true
this._emitter.emit('cdp-connection-reconnect-error', cdpError)
}
}
this._reconnection = undefined
}
private _broadcastEvent = ({ method, params, sessionId }: { method: CdpEvent, params: Record<string, any>, sessionId?: string }) => {
this.verboseDebug('rebroadcasting event', method, params, sessionId)
this._emitter.emit('event', { method, params, sessionId })
this._emitter.emit(method, params, sessionId)
this._emitter.emit(`${method}.${sessionId}`, params)
}
}