Files
cypress/packages/socket/lib/node/cdp-socket.ts

178 lines
5.0 KiB
TypeScript

import type { CDPClient } from '@packages/types/src/protocol'
import type Protocol from 'devtools-protocol/types/protocol.d'
import { EventEmitter } from 'stream'
import { randomUUID } from 'crypto'
import { decode, encode } from '../utils'
import Debug from 'debug'
const debugVerbose = Debug('cypress-verbose:server:socket:cdp-socket')
/**
* The goal of this class is to emulate the socket io server API, but using the Chrome DevTools Protocol.
*/
export class CDPSocketServer extends EventEmitter {
private _cdpSocket?: CDPSocket
private _fullNamespace: string
private _path?: string
private _namespaceMap: Record<string, CDPSocketServer> = {}
constructor ({ path = '', namespace = '/default' } = {}) {
super()
this._path = path
this._fullNamespace = `${path}${namespace}`
}
async attachCDPClient (cdpClient: CDPClient): Promise<void> {
this._cdpSocket = await CDPSocket.init(cdpClient, this._fullNamespace)
await Promise.all(Object.values(this._namespaceMap).map(async (server) => {
return server.attachCDPClient(cdpClient)
}))
// Simulate a connection event
super.emit('connection', this._cdpSocket)
}
// @ts-expect-error TODO: fix emit type
emit = async (event: string, ...args: any[]) => {
// tslint:disable-next-line:no-floating-promises
this._cdpSocket?.emit(event, ...args)
return true
}
of (namespace: string): CDPSocketServer {
const fullNamespace = `${this._path}${namespace}`
let server = this._namespaceMap[fullNamespace]
if (!server) {
server = new CDPSocketServer({ path: this._path, namespace })
this._namespaceMap[fullNamespace] = server
}
return server
}
// We want to match the socket io API, but we don't really need to support rooms, so we are just passing along the existing server in this case.
to (): CDPSocketServer {
return this
}
close (): void {
this._cdpSocket?.close()
this.removeAllListeners()
this._cdpSocket = undefined
Object.values(this._namespaceMap).forEach((server) => {
server.close()
})
}
disconnectSockets (close?: boolean): void {
this.close()
}
}
export class CDPSocket extends EventEmitter {
private _cdpClient?: CDPClient
private _namespace: string
private _executionContextId?: number
constructor (cdpClient: CDPClient, namespace: string) {
super()
this._cdpClient = cdpClient
this._namespace = namespace
this._cdpClient.on('Runtime.bindingCalled', this.processCDPRuntimeBinding)
}
static async init (cdpClient: CDPClient, namespace: string): Promise<CDPSocket> {
await cdpClient.send('Runtime.enable')
await cdpClient.send('Runtime.addBinding', {
name: `cypressSendToServer-${namespace}`,
})
return new CDPSocket(cdpClient, namespace)
}
join = (): void => {
return
}
// @ts-expect-error TODO: fix emit type
emit = async (event: string, ...args: any[]) => {
// Generate a unique callback event name
const uuid = randomUUID()
let callback: ((...args: any[]) => void) | undefined
if (typeof args[args.length - 1] === 'function') {
callback = args.pop()
}
if (callback) {
this.once(uuid, callback)
}
await encode([event, uuid, args], this._namespace).then((encoded: any) => {
const expression = `
if (window['cypressSocket-${this._namespace}'] && window['cypressSocket-${this._namespace}'].send) {
window['cypressSocket-${this._namespace}'].send('${JSON.stringify(encoded).replaceAll('\\', '\\\\').replaceAll('\'', '\\\'')}')
}
`
debugVerbose('sending message to browser %o', { expression })
this._cdpClient?.send('Runtime.evaluate', { expression, contextId: this._executionContextId }).then((result) => {
debugVerbose('successfully sent message to browser %o', result)
}).catch((error) => {
debugVerbose('error sending message to browser %o', { error })
})
})
return true
}
disconnect = () => {
this.close()
}
get connected (): boolean {
return !!this._cdpClient
}
close = () => {
this._cdpClient?.off('Runtime.bindingCalled', this.processCDPRuntimeBinding)
this._cdpClient = undefined
}
private processCDPRuntimeBinding = async (bindingCalledEvent: Protocol.Runtime.BindingCalledEvent) => {
const { name, payload } = bindingCalledEvent
if (name !== `cypressSendToServer-${this._namespace}`) {
return
}
debugVerbose('received message from browser %o', { payload })
this._executionContextId = bindingCalledEvent.executionContextId
const data = JSON.parse(payload)
await decode(data).then((decoded: any) => {
const [event, callbackEvent, args] = decoded
const callback = async (...callbackArgs: any[]) => {
debugVerbose('emitting callback from browser %o', { callbackEvent, callbackArgs })
await this.emit(callbackEvent, ...callbackArgs)
}
debugVerbose('emitting message from browser %o', { event, callbackEvent, args })
super.emit(event, ...args, callback)
})
}
}