PSChromeDevToolsServer.psm1

# $global:DebugPreference = 'Continue'

$script:Powershell = $null

function Initialize {
    $script:Powershell = [powershell]::Create()
    $script:Powershell.AddScript( {
            function New-UnboundClassInstance ([Type] $type, [object[]] $arguments) {
                [activator]::CreateInstance($type, $arguments)
            }
        }.Ast.GetScriptBlock()
    ).Invoke()
    $script:Powershell.Commands.Clear()
}

function New-UnboundClassInstance ([Type] $type, [object[]] $arguments = $null) {
    if ($null -eq $script:Powershell) { Initialize }

    try {
        if ($null -eq $arguments) { $arguments = @() }
        $result = $script:Powershell.AddCommand('New-UnboundClassInstance').
        AddParameter('type', $type).
        AddParameter('arguments', $arguments).
        Invoke()
        return $result
    } finally {
        $script:Powershell.Commands.Clear()
    }
}

function ConvertTo-Delegate {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
        [System.Management.Automation.PSMethod[]]$Method,
        [Parameter(Mandatory)]
        [object]$Target
    )

    process {
        $reflectionMethod = if ($Target.GetType().Name -eq 'PSCustomObject') {
            $Target.psobject.GetType().GetMethod($Method.Name)
        } else {
            $Target.GetType().GetMethod($Method.Name)
        }
        $parameterTypes = [System.Linq.Enumerable]::Select($reflectionMethod.GetParameters(), [func[object, object]] { $args[0].parametertype })
        $concatMethodTypes = $parameterTypes + $reflectionMethod.ReturnType
        $delegateType = [System.Linq.Expressions.Expression]::GetDelegateType($concatMethodTypes)
        $delegate = [delegate]::CreateDelegate($delegateType, $Target, $reflectionMethod.Name)
        $delegate
    }
}


class CdpPage {
    [string]$TargetId
    [string]$Url
    [string]$Title
    [string]$SessionId
    [int]$ProcessId

    CdpPage() {}

    CdpPage($TargetId, $Url, $Title) {
        $this.TargetId = $TargetId
        $this.Url = $Url
        $this.Title = $Title
    }

    CdpPage($TargetId, $Url, $Title, $SessionId) {
        $this.SessionId = $SessionId
        $this.TargetId = $TargetId
        $this.Url = $Url
        $this.Title = $Title
    }

    [bool]$IsLoading = $false
    [int]$DomContentEventFired = 0
    [int]$LoadEventFired = 0
    [int]$FrameStoppedLoading = 0
    [int]$FrameStartedLoading = 0
    [System.Collections.Concurrent.ConcurrentDictionary[string, object]]$Frames = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()
    [string]$RuntimeUniqueId
    [string]$ObjectId
    [object]$Node
    [object]$BoxModel
}


# [NoRunspaceAffinity()]
class CdpEventHandler {
    [System.Collections.Generic.Dictionary[string, object]]$SharedState
    [hashtable]$EventHandlers

    CdpEventHandler([System.Collections.Concurrent.ConcurrentDictionary[string, object]]$SharedState) {
        $this.SharedState = $SharedState
        $this.InitializeHandlers()
    }

    hidden [void]InitializeHandlers() {
        $this.EventHandlers = @{
            'Page.domContentEventFired' = $this.DomContentEventFired
            'Page.frameAttached' = $this.FrameAttached
            'Page.frameDetached' = $this.FrameDetached
            'Page.frameNavigated' = $this.FrameNavigated
            'Page.loadEventFired' = $this.LoadEventFired
            'Page.frameRequestedNavigation' = $this.FrameRequestedNavigation
            'Page.frameStartedLoading' = $this.FrameStartedLoading
            'Page.frameStartedNavigating' = $this.FrameStartedNavigating
            'Page.frameStoppedLoading' = $this.FrameStoppedLoading
            'Page.navigatedWithinDocument' = $this.NavigatedWithinDocument
            'Target.targetCreated' = $this.TargetCreated
            'Target.targetDestroyed' = $this.TargetDestroyed
            'Target.targetInfoChanged' = $this.TargetInfoChanged
            'Target.attachedToTarget' = $this.AttachedToTarget
            'Target.detachedFromTarget' = $this.DetachedFromTarget
            'Runtime.bindingCalled' = $this.BindingCalled
            'Runtime.executionContextsCleared' = $this.ExecutionContextsCleared
            'Runtime.executionContextCreated' = $this.ExecutionContextCreated
        }
    }

    [void]ProcessEvent($Response) {
        if ($null -eq $Response.method) { return }
        $handler = $this.EventHandlers[$Response.method]
        if ($handler) {
            $handler.Invoke($Response)
        }
        # else {
        # Write-Debug ('Unprocessed Event: ({0})' -f $Response.method)
        # }
    }

    hidden [void]DomContentEventFired($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.sessionId)
        $CdpPage.DomContentEventFired++
        if ($CdpPage.LoadEventFired -eq $CdpPage.DomContentEventFired) {
            $CdpPage.IsLoading = $false
        }

        $Callback = $this.SharedState.Callbacks['OnDomContentEventFired']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]FrameAttached($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.sessionId)
        if ($CdpPage) {
            $CdpPage.Frames.TryAdd($Response.params.frameId,
                [pscustomobject]@{
                    FrameStartedLoading = 0
                    FrameStoppedLoading = 0
                    FrameId = $Response.params.frameId
                    ParentFrameId = $Response.params.parentFrameId
                    SessionId = $Response.sessionId
                    RuntimeUniqueId = $null
                    IsLoading = $false
                }
            )
        }

        $Callback = $this.SharedState.Callbacks['OnFrameAttached']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]FrameDetached($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.sessionId)
        if ($CdpPage -and $Response.params.reason -eq 'remove') {
            $null = $CdpPage.Frames.TryRemove($Response.params.frameId, [ref]$null)
        }

        $Callback = $this.SharedState.Callbacks['OnFrameDetached']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]FrameNavigated($Response) {
        # Write-Debug ('Frame Navigated: ({0})' -f ($Response | ConvertTo-Json -Depth 10))

        $Callback = $this.SharedState.Callbacks['OnFrameNavigated']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]LoadEventFired($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.sessionId)
        $CdpPage.LoadEventFired++
        if ($CdpPage.LoadEventFired -eq $CdpPage.DomContentEventFired) {
            $CdpPage.IsLoading = $false
        }
        $Callback = $this.SharedState.Callbacks['OnLoadEventFired']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]FrameRequestedNavigation($Response) {
        # Write-Debug ('Frame Requested Navigation: ({0})' -f ($Response | ConvertTo-Json -Depth 10))

        $Callback = $this.SharedState.Callbacks['OnFrameRequestedNavigation']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]FrameStartedLoading($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.sessionId)
        if ($CdpPage.TargetId -eq $Response.params.frameId) {
            $CdpPage.FrameStartedLoading++
        } else {
            $Frame = $null
            if ($CdpPage.Frames.TryGetValue($Response.params.frameId, [ref]$Frame)) {
                $Frame.IsLoading = $true
                $Frame.FrameStartedLoading++
            }
            # Write-Debug ('Start CdpPage: ({0})' -f ($CdpPage | ConvertTo-Json -Depth 10))
            # Write-Debug ('Start Frame: ({0})' -f ($Frame | ConvertTo-Json -Depth 10))
        }

        $Callback = $this.SharedState.Callbacks['OnFrameStartedLoading']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]FrameStartedNavigating($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.sessionId)
        $CdpPage.IsLoading = $true
        # Write-Debug ('Frame Started Navigating: ({0})' -f ($Response | ConvertTo-Json -Depth 10))

        $Callback = $this.SharedState.Callbacks['OnFrameStartedNavigating']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]FrameStoppedLoading($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.sessionId)
        if ($CdpPage.TargetId -eq $Response.params.frameId) {
            $CdpPage.FrameStoppedLoading++
        } else {
            $Frame = $null
            if ($CdpPage.Frames.TryGetValue($Response.params.frameId, [ref]$Frame)) {
                $Frame.FrameStoppedLoading++
                $Frame.IsLoading = $false
            }
            # Write-Debug ('Stop CdpPage: ({0})' -f ($CdpPage | ConvertTo-Json -Depth 10))
            # Write-Debug ('Stop Frame: ({0})' -f ($Frame | ConvertTo-Json -Depth 10))
        }

        $Callback = $this.SharedState.Callbacks['OnFrameStoppedLoading']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]NavigatedWithinDocument($Response) {
        # Write-Debug ('Navigated Within Document: ({0})' -f ($Response | ConvertTo-Json -Depth 10))

        $Callback = $this.SharedState.Callbacks['OnNavigatedWithinDocument']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]TargetCreated($Response) {
        $Target = $Response.params.targetInfo
        $CdpPage = [CdpPage]::new($Target.targetId, $Target.Url, $Target.Title, $Response.params.sessionId)
        $null = $this.SharedState.Targets.TryAdd($Target.targetId, $CdpPage)

        $Callback = $this.SharedState.Callbacks['OnTargetCreated']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]TargetDestroyed($Response) {
        $CdpPage = $this.GetPageByTargetId($Response.params.targetId)
        if ($CdpPage) {
            $null = $this.SharedState.Targets.TryRemove($CdpPage.TargetId, [ref]$null)
        }

        $Callback = $this.SharedState.Callbacks['OnTargetDestroyed']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]TargetInfoChanged($Response) {
        $Target = $Response.params.targetInfo
        $CdpPage = $this.GetPageByTargetId($Target.targetId)
        if ($CdpPage) {
            $CdpPage.Url = $Target.Url
            $CdpPage.Title = $Target.Title
            $CdpPage.ProcessId = $Target.pid
            # $CdpPage.Frames.Clear()
        }

        $Callback = $this.SharedState.Callbacks['OnTargetInfoChanged']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]AttachedToTarget($Response) {
        $Target = $Response.params.targetInfo
        $CdpPage = $this.GetPageByTargetId($Target.targetId)
        $CdpPage.sessionId = $Response.params.sessionId

        $Callback = $this.SharedState.Callbacks['OnAttachedToTarget']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]DetachedFromTarget($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.params.sessionId)
        $CdpPage.sessionId = $null

        $Callback = $this.SharedState.Callbacks['OnDetachedFromTarget']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]BindingCalled($Response) {
        $Callback = $this.SharedState.Callbacks['OnBindingCalled']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]ExecutionContextsCleared($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.sessionId)
        if ($CdpPage) {
            $CdpPage.RuntimeUniqueId = $null
        }

        $Callback = $this.SharedState.Callbacks['OnExecutionContextsCleared']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    hidden [void]ExecutionContextCreated($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.sessionId)
        $FrameId = $Response.params.context.auxData.frameId
        if ($CdpPage.TargetId -eq $FrameId) {
            $CdpPage.RuntimeUniqueId = $Response.params.context.uniqueId
        } else {
            $Frame = $null
            if ($CdpPage.Frames.TryGetValue($FrameId, [ref]$Frame)) {
                $Frame.RuntimeUniqueId = $Response.params.context.uniqueId
            }
        }

        $Callback = $this.SharedState.Callbacks['OnExecutionContextCreated']
        if ($Callback) {
            $Callback.Invoke($Response)
        }
    }

    [CdpPage]GetPageBySessionId([string]$SessionId) {
        $Page = $null
        $Target = $this.SharedState.Targets.ToArray().Value.Where({
                $_.SessionId -eq $SessionId
            }
        )
        $null = $this.SharedState.Targets.TryGetValue($Target.TargetId, [ref]$Page)
        return $Page
    }

    hidden [CdpPage]GetPageByTargetId([string]$TargetId) {
        $Page = $null
        $Found = $this.SharedState.Targets.ToArray().Value.Where({ $_.TargetId -eq $TargetId })
        $null = $this.SharedState.Targets.TryGetValue($Found.TargetId, [ref]$Page)
        return $Page
    }
}

# [NoRunspaceAffinity()]
class CdpServer {
    [System.Collections.Concurrent.ConcurrentDictionary[string, object]]$SharedState = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()
    [System.Management.Automation.Runspaces.RunspacePool]$RunspacePool
    [System.Diagnostics.Process]$ChromeProcess
    [pscustomobject]$Threads = @{
        MessageReader = $null
        MessageReaderHandle = $null
        MessageProcessor = $null
        MessageProcessorHandle = $null
        MessageWriter = $null
        MessageWriterHandle = $null
    }

    CdpServer($StartPage, $UserDataDir, $BrowserPath, $StreamOutput) {
        $this.Init($StartPage, $UserDataDir, $BrowserPath, $StreamOutput, 0, $null)
    }

    CdpServer($StartPage, $UserDataDir, $BrowserPath, $StreamOutput, $AdditionalThreads) {
        $this.Init($StartPage, $UserDataDir, $BrowserPath, $StreamOutput, 0, $null)
    }

    CdpServer($StartPage, $UserDataDir, $BrowserPath, $StreamOutput, $AdditionalThreads, $Callbacks) {
        $this.Init($StartPage, $UserDataDir, $BrowserPath, $StreamOutput, $AdditionalThreads, $Callbacks)
    }

    hidden [void]Init($StartPage, $UserDataDir, $BrowserPath, $StreamOutput, $AdditionalThreads, $Callbacks) {
        $this.SharedState = [System.Collections.Generic.Dictionary[string, object]]::new()

        $this.SharedState.IO = @{
            PipeWriter = [System.IO.Pipes.AnonymousPipeServerStream]::new([System.IO.Pipes.PipeDirection]::Out, [System.IO.HandleInheritability]::Inheritable)
            PipeReader = [System.IO.Pipes.AnonymousPipeServerStream]::new([System.IO.Pipes.PipeDirection]::In, [System.IO.HandleInheritability]::Inheritable)
            UnprocessedResponses = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
            CommandQueue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
        }

        $this.SharedState.MessageHistory = [System.Collections.Concurrent.ConcurrentDictionary[version, object]]::new()
        $this.SharedState.CommandId = 0
        $this.SharedState.Targets = [System.Collections.Concurrent.ConcurrentDictionary[string, CdpPage]]::new()
        $this.SharedState.Callbacks = [System.Collections.Generic.Dictionary[string, scriptblock]]::new()

        foreach ($Key in $Callbacks.Keys) {
            $this.SharedState.Callbacks[$Key] = $Callbacks[$Key]
        }

        $this.SharedState.Commands = @{
            SendRuntimeEvaluate = $this.CreateDelegate($this.SendRuntimeEvaluate)
            GetPageBySessionId = $this.CreateDelegate($this.GetPageBySessionId)
            GetPageByTargetId = $this.CreateDelegate($this.GetPageByTargetId)
        }

        $this.SharedState.EventHandler = New-UnboundClassInstance -type ([CdpEventHandler]) -arguments @($this.SharedState) #[CdpEventHandler]::new($this.SharedState)

        $State = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
        $State.ImportPSModule("$PSScriptRoot\PSChromeDevToolsServer")
        $RunspaceSharedState = [System.Management.Automation.Runspaces.SessionStateVariableEntry]::new('SharedState', $this.SharedState, $null)
        $State.Variables.Add($RunspaceSharedState)
        $State.ThrowOnRunspaceOpenError = $true
        $this.RunspacePool = [RunspaceFactory]::CreateRunspacePool(3, 3 + $AdditionalThreads, $State, $StreamOutput)
        $this.RunspacePool.Open()

        $BrowserArgs = @(
            ('--user-data-dir="{0}"' -f $UserDataDir)
            '--no-first-run'
            '--remote-debugging-pipe'
            ('--remote-debugging-io-pipes={0},{1}' -f $this.SharedState.IO.PipeWriter.GetClientHandleAsString(), $this.SharedState.IO.PipeReader.GetClientHandleAsString())
            $StartPage
        ) | Where-Object { $_ -ne '' -and $_ -ne $null }

        $StartInfo = [System.Diagnostics.ProcessStartInfo]::new()
        $StartInfo.FileName = $BrowserPath
        $StartInfo.Arguments = $BrowserArgs
        $StartInfo.UseShellExecute = $false

        $this.ChromeProcess = [System.Diagnostics.Process]::Start($StartInfo)

        while (!$this.SharedState.IO.PipeWriter.IsConnected -and !$this.SharedState.IO.PipeReader.IsConnected) {
            Start-Sleep -Milliseconds 50
        }

        $this.SharedState.IO.PipeWriter.DisposeLocalCopyOfClientHandle()
        $this.SharedState.IO.PipeReader.DisposeLocalCopyOfClientHandle()
    }

    [void]StartMessageReader() {
        $this.Threads.MessageReader = [powershell]::Create()
        $this.Threads.MessageReader.RunspacePool = $this.RunspacePool
        $null = $this.Threads.MessageReader.AddScript({
                if ($SharedState.DebugPreference) { $DebugPreference = $SharedState.DebugPreference }
                if ($SharedState.VerbosePreference) { $VerbosePreference = $SharedState.VerbosePreference }

                $Buffer = [byte[]]::new(1024)
                $StringBuilder = [System.Text.StringBuilder]::new()
                $NullTerminatedString = "`0"

                while ($SharedState.IO.PipeReader.IsConnected) {
                    # Will hang here until something comes through the pipe.
                    $BytesRead = $SharedState.IO.PipeReader.Read($Buffer, 0, $Buffer.Length)
                    $null = $StringBuilder.Append([System.Text.Encoding]::UTF8.GetString($Buffer, 0, $BytesRead))

                    $HasCompletedMessages = if ($StringBuilder.Length) { $StringBuilder.ToString($StringBuilder.Length - 1, 1) -eq $NullTerminatedString } else { $false }
                    if ($HasCompletedMessages) {
                        $RawResponse = $StringBuilder.ToString()
                        $SplitResponse = @(($RawResponse -split $NullTerminatedString).Where({ "`0" -ne $_ }) | ConvertFrom-Json)
                        $SplitResponse.ForEach({
                                $SharedState.IO.UnprocessedResponses.Enqueue($_)
                            }
                        )
                        $StringBuilder.Clear()
                    }
                }
            }
        )
        $this.Threads.MessageReaderHandle = $this.Threads.MessageReader.BeginInvoke()
    }

    [void]StartMessageProcessor() {
        $this.Threads.MessageProcessor = [powershell]::Create()
        $this.Threads.MessageProcessor.RunspacePool = $this.RunspacePool
        $null = $this.Threads.MessageProcessor.AddScript({
                if ($SharedState.DebugPreference) { $DebugPreference = $SharedState.DebugPreference }
                if ($SharedState.VerbosePreference) { $VerbosePreference = $SharedState.VerbosePreference }

                $Response = $null
                $IdleTime = 1
                $ResponseIndex = 1

                while ($SharedState.IO.PipeReader.IsConnected -and $SharedState.IO.PipeWriter.IsConnected) {
                    while ($SharedState.IO.UnprocessedResponses.TryDequeue([ref]$Response)) {

                        $LastCommandId = $null
                        if ($Response.id) {
                            $LastCommandId = $Response.id
                        } else {
                            while (!$SharedState.TryGetValue('CommandId', [ref]$LastCommandId)) {
                                Write-Host "couldn't get commandid: $($Response.id) method: $($Response.method) error: $($Response.error)" -ForegroundColor Red
                                Start-Sleep -Milliseconds 50
                            }
                        }

                        do {
                            $SucessfullyAdded = if ($Response.id) {
                                $SharedState.MessageHistory.GetOrAdd([version]::new($LastCommandId, 0), $Response)

                                if (!$SucessfullyAdded) {
                                    Write-Host "couldn't add message: $($Response.id) $SucessfullyAdded" -ForegroundColor Red
                                }
                            } else {
                                $SharedState.MessageHistory.TryAdd([version]::new($LastCommandId, $ResponseIndex++), $Response)
                            }
                        } while (!$SucessfullyAdded)

                        # $Start = Get-Date
                        $SharedState.EventHandler.ProcessEvent($Response)
                        # $End = Get-Date
                        # Write-Debug ('{0} {1} Processing Time: {2} ms' -f $Response.id, $Response.method, ($End - $Start).TotalMilliseconds)
                    }
                    Start-Sleep -Seconds $IdleTime
                }
            }
        )
        $this.Threads.MessageProcessorHandle = $this.Threads.MessageProcessor.BeginInvoke()
    }

    [void]StartMessageWriter() {
        $this.Threads.MessageWriter = [powershell]::Create()
        $this.Threads.MessageWriter.RunspacePool = $this.RunspacePool
        $null = $this.Threads.MessageWriter.AddScript({
                if ($SharedState.DebugPreference) { $DebugPreference = $SharedState.DebugPreference }
                if ($SharedState.VerbosePreference) { $VerbosePreference = $SharedState.VerbosePreference }

                $CommandBytes = $null
                $IdleTime = 1
                while ($SharedState.IO.PipeReader.IsConnected -and $SharedState.IO.PipeWriter.IsConnected) {
                    while ($SharedState.IO.CommandQueue.TryDequeue([ref]$CommandBytes)) {
                        $SharedState.IO.PipeWriter.Write($CommandBytes, 0, $CommandBytes.Length)
                    }
                    Start-Sleep -Seconds $IdleTime
                }
            }
        )
        $this.Threads.MessageWriterHandle = $this.Threads.MessageWriter.BeginInvoke()
    }

    [void]Stop() {
        $this.SharedState.IO.PipeReader.Dispose()
        $this.SharedState.IO.PipeWriter.Dispose()
        while ($this.SharedState.IO.PipeReader.IsConnected -or $this.SharedState.IO.PipeWriter.IsConnected) {
            Start-Sleep -Milliseconds 50
        }
        if ($this.Threads.MessageReaderHandle) {
            $this.Threads.MessageReader.EndInvoke($this.Threads.MessageReaderHandle)
            $this.Threads.MessageReader.Dispose()
        }
        if ($this.Threads.MessageProcessorHandle) {
            $this.Threads.MessageProcessor.EndInvoke($this.Threads.MessageProcessorHandle)
            $this.Threads.MessageProcessor.Dispose()
        }
        if ($this.Threads.MessageWriterHandle) {
            $this.Threads.MessageWriter.EndInvoke($this.Threads.MessageWriterHandle)
            $this.Threads.MessageWriter.Dispose()
        }
        $this.ChromeProcess.Dispose()
        $this.RunspacePool.Dispose()
    }

    [void]SendCommand([hashtable]$Command) {
        $this.SendCommand($Command, $false)
    }

    [object]SendCommand([hashtable]$Command, [bool]$WaitForResponse) {
        # This should be the only place where $this.SharedState.CommandId is incremented.
        $CommandId = $this.SharedState.AddOrUpdate('CommandId', 1, { param($Key, $OldValue) $OldValue + 1 })

        $Command.id = $CommandId
        $JsonCommand = $Command | ConvertTo-Json -Depth 10 -Compress
        $CommandBytes = [System.Text.Encoding]::UTF8.GetBytes($JsonCommand) + 0
        $this.SharedState.IO.CommandQueue.Enqueue($CommandBytes)
        if ($WaitForResponse) {
            $AwaitedMessage = $null
            while (!$this.SharedState.MessageHistory.TryGetValue([version]::new($CommandId, 0), [ref]$AwaitedMessage)) {
                Start-Sleep -Milliseconds 50
            }
            return $AwaitedMessage
        }
        return $null
    }

    [CdpPage]GetPageBySessionId([string]$SessionId) {
        $Page = $null
        $Target = $this.SharedState.Targets.ToArray().Value.Where({
                $_.SessionId -eq $SessionId
            }
        )
        $this.SharedState.Targets.TryGetValue($Target.TargetId, [ref]$Page)
        return $Page
    }

    [CdpPage]GetPageByTargetId([string]$TargetId) {
        $Page = $null
        $Found = $this.SharedState.Targets.ToArray().Value.Where({ $_.TargetId -eq $TargetId })
        $null = $this.SharedState.Targets.TryGetValue($Found.TargetId, [ref]$Page)
        return $Page
    }

    [void]SendPageEnable([string]$SessionId) {
        $JsonCommand = [CdpCommandPage]::enable($SessionId)
        $this.SendCommand($JsonCommand)
    }

    [void]SendRuntimeEnable([string]$SessionId) {
        $JsonCommand = [CdpCommandRuntime]::enable($SessionId)
        $this.SendCommand($JsonCommand)
    }

    [void]SendRuntimeEvaluate([string]$SessionId, [string]$Expression) {
        $JsonCommand = [CdpCommandRuntime]::evaluate($SessionId, $Expression)
        $this.SendCommand($JsonCommand)
    }

    [void]SendRuntimeAddBinding([string]$SessionId, [string]$Name) {
        $JsonCommand = [CdpCommandRuntime]::addBinding($SessionId, $Name)
        $this.SendCommand($JsonCommand)
    }

    [void]EnableDefaultEvents() {
        $JsonCommand = [CdpCommandTarget]::setDiscoverTargets()
        $this.SendCommand($JsonCommand)

        $JsonCommand = [CdpCommandTarget]::setAutoAttach()
        $this.SendCommand($JsonCommand)

        while ($this.SharedState.Targets.Count -eq 0) {
            Start-Sleep -Milliseconds 50
        }

        while (!$this.SharedState.Targets.Values[0].SessionId) {
            Start-Sleep -Milliseconds 50
        }

        $this.SendPageEnable($this.SharedState.Targets.Values[0].SessionId)
        $this.SendRuntimeEnable($this.SharedState.Targets.Values[0].SessionId)
        # $this.SendRuntimeAddBinding($this.SharedState.Targets.Values[0].SessionId, 'PowershellServer')
    }

    [object]ShowMessageHistory() {
        return $this.SharedState.MessageHistory.GetEnumerator() | Sort-Object -Property Key | Select-Object -Property @(
            @{Name = 'id'; Expression = { $_.Value.id } },
            @{Name = 'method'; Expression = { $_.Value.method } },
            @{Name = 'error'; Expression = { $_.Value.error } },
            @{Name = 'sessionId'; Expression = { $_.Value.sessionId } },
            @{Name = 'result'; Expression = { $_.Value.result } },
            @{Name = 'params'; Expression = { $_.Value.params } }
        )
    }

    hidden [Delegate]CreateDelegate([System.Management.Automation.PSMethod]$Method) {
        return $this.CreateDelegate($Method, $this)
    }

    hidden [Delegate]CreateDelegate([System.Management.Automation.PSMethod]$Method, $Target) {
        $reflectionMethod = if ($Target.GetType().Name -eq 'PSCustomObject') {
            $Target.psobject.GetType().GetMethod($Method.Name)
        } else {
            $Target.GetType().GetMethod($Method.Name)
        }
        $parameterTypes = [System.Linq.Enumerable]::Select($reflectionMethod.GetParameters(), [func[object, object]] { $args[0].parametertype })
        $concatMethodTypes = $parameterTypes + $reflectionMethod.ReturnType
        $delegateType = [System.Linq.Expressions.Expression]::GetDelegateType($concatMethodTypes)
        $delegate = [delegate]::CreateDelegate($delegateType, $Target, $reflectionMethod.Name)
        return $delegate
    }
}

class CdpCommandDom {
    static [hashtable]describeNode($SessionId, $ObjectId) {
        return @{
            method = 'DOM.describeNode'
            sessionId = $SessionId
            params = @{
                objectId = "$ObjectId"
            }
        }
    }
    static [hashtable]getBoxModel($SessionId, $ObjectId) {
        return @{
            method = 'DOM.getBoxModel'
            sessionId = $SessionId
            params = @{
                objectId = "$ObjectId"
            }
        }
    }
}

class CdpCommandInput {
    static [hashtable]dispatchKeyEvent($SessionId, $Text) {
        return @{
            method = 'Input.dispatchKeyEvent'
            sessionId = $SessionId
            params = @{
                type = 'char'
                text = $Text
            }
        }
    }
    static [hashtable]dispatchMouseEvent($SessionId, $Type, $X, $Y, $Button) {
        return @{
            method = 'Input.dispatchMouseEvent'
            sessionId = $SessionId
            params = @{
                type = $Type
                button = $Button
                clickCount = 0
                x = $X
                y = $Y
            }
        }
    }
}

class CdpCommandPage {
    static [hashtable]enable($SessionId) {
        return @{
            method = 'Page.enable'
            sessionId = $SessionId
        }
    }
    static [hashtable]navigate($SessionId, $Url) {
        return @{
            method = 'Page.navigate'
            sessionId = $SessionId
            params = @{
                url = $Url
            }
        }
    }
}

class CdpCommandRuntime {
    static [hashtable]addBinding($SessionId, $Name) {
        return @{
            method = 'Runtime.addBinding'
            sessionId = $SessionId
            params = @{
                name = $Name
            }
        }
    }
    static [hashtable]enable($SessionId) {
        return @{
            method = 'Runtime.enable'
            sessionId = $SessionId
        }
    }
    static [hashtable]evaluate($SessionId, $Expression) {
        return @{
            method = 'Runtime.evaluate'
            sessionId = $SessionId
            params = @{
                expression = $Expression
            }
        }
    }
}

class CdpCommandTarget {
    static [hashtable]createTarget($Url) {
        return @{
            method = 'Target.createTarget'
            params = @{
                url = $Url
            }
        }
    }
    static [hashtable]setAutoAttach() {
        return @{
            method = 'Target.setAutoAttach'
            params = @{
                autoAttach = $true
                waitForDebuggerOnStart = $false
                filter = @(
                    @{
                        type = 'service_worker'
                        exclude = $true
                    },
                    @{
                        type = 'worker'
                        exclude = $true
                    },
                    @{
                        type = 'browser'
                        exclude = $true
                    },
                    @{
                        type = 'tab'
                        exclude = $true
                    },
                    # @{
                    # type = 'other'
                    # exclude = $true
                    # },
                    @{
                        type = 'background_page'
                        exclude = $true
                    },
                    @{}
                )
                flatten = $true
            }
        }
    }
    static [hashtable]setDiscoverTargets() {
        return @{
            method = 'Target.setDiscoverTargets'
            params = @{
                discover = $true
                filter = @(
                    @{
                        type = 'service_worker'
                        exclude = $true
                    },
                    @{
                        type = 'worker'
                        exclude = $true
                    },
                    @{
                        type = 'browser'
                        exclude = $true
                    },
                    @{
                        type = 'tab'
                        exclude = $true
                    },
                    # @{
                    # type = 'other'
                    # exclude = $true
                    # },
                    @{
                        type = 'background_page'
                        exclude = $true
                    },
                    @{}
                )
            }
        }
    }
}

function Start-CdpServer {
    <#
        .SYNOPSIS
        Starts the CdpServer by launching the browser process, initializing the event handlers, and starting the message reader, processor, and writer threads
        .PARAMETER StartPage
        The URL of the page to load when the browser starts
        .PARAMETER UserDataDir
        The directory to use for the browser's user data profile. This should be a unique directory for each instance of the server to avoid conflicts
        .PARAMETER BrowserPath
        The path to the browser executable to launch
        .PARAMETER AdditionalThreads
        Sets the max runspaces the pool can use + 3.
        Default runspacepool uses 3min and 3max threads for MessageReader, MessageProcessor, MessageWriter
        A number higher than 0 increases the maximum runspaces for the pool.

        More MessageProcessor can be started with $CdpServer.MessageProcessor()
        These will be queued forever if the max number of runspaces are exhausted in the pool.
        .PARAMETER Callbacks
        A hashtable of scriptblocks to be invoked for specific events. The keys should be the event names without the domain prefix and preceeded by 'On'. For example:
        @{
            OnLoadEventFired = { param($Response) $Response.params }
        }
        .PARAMETER DisableDefaultEvents
        This stops targets from being auto attached and auto discovered.
        .PARAMETER StreamOutput
        This is the $Host/Console which runspace streams will output to.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$StartPage,
        [Parameter(Mandatory)]
        [ValidateScript({ Test-Path $_ -PathType Container -IsValid })]
        [string]$UserDataDir,
        [Parameter(Mandatory)]
        [string]$BrowserPath,
        [ValidateScript({ $_ -ge 0 })]
        [int]$AdditionalThreads = 0,
        [hashtable]$Callbacks,
        [switch]$DisableDefaultEvents,
        [object]$StreamOutput
    )

    # $Server = [CdpServer]::new($StartPage, $UserDataDir, $BrowserPath, $AdditionalThreads, $Callbacks)
    $ConsoleHost = if ($StreamOutput) { $StreamOutput } else { (Get-Host) }
    $Server = New-UnboundClassInstance CdpServer -arguments $StartPage, $UserDataDir, $BrowserPath, $ConsoleHost, $AdditionalThreads, $Callbacks
    if ($PSBoundParameters.ContainsKey('Debug')) {
        $Server.SharedState.DebugPreference = 'Continue'
    }
    if ($PSBoundParameters.ContainsKey('Verbose')) {
        $Server.SharedState.VerbosePreference = 'Continue'
    }
    $Server.StartMessageReader()
    $Server.StartMessageProcessor()
    $Server.StartMessageWriter()

    if (!$DisableDefaultEvents) {
        $Server.EnableDefaultEvents()
    }

    while ($Server.SharedState.Targets.Count -eq 0 -or $null -eq $Server.SharedState.Targets.Values[0].RuntimeUniqueId -or $null -eq $Server.SharedState.Targets.Values[0].SessionId) {
        Start-Sleep -Seconds 1
    }

    $Server
}

function Stop-CdpServer {
    <#
        .SYNOPSIS
        Disposes the Server Pipes, Threads, ChromeProcess, and RunspacePool
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [CdpServer]$Server
    )

    $Server.Stop()
}

function New-CdpPage {
    <#
        .SYNOPSIS
        Creates a new target and returns the corresponding CdpPage object from the server's SharedState.Targets list
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [CdpServer]$Server,
        [string]$Url = 'about:blank',
        [switch]$NewWindow
    )
    $Command = [CdpCommandTarget]::createTarget($Url)
    if ($NewWindow) { $Command.params.newWindow = $true }
    $Response = $Server.SendCommand($Command, $true)

    $CdpPage = $Server.GetPageByTargetId($Response.result.targetId)

    $Command = [CdpCommandPage]::enable($CdpPage.SessionId)
    $Server.SendCommand($Command)
    $Command = [CdpCommandRuntime]::enable($CdpPage.SessionId)
    $null = $Server.SendCommand($Command, $true)

    $CdpPage
}

function Invoke-CdpPageNavigate {
    <#
        .SYNOPSIS
        Navigates and automatically waits for the page to load with LoadEventFired and FrameStoppedLoading
        Also waits for frames to load if they are present
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [CdpServer]$Server,
        [Parameter(Mandatory)]
        [string]$SessionId,
        [Parameter(Mandatory)]
        [string]$Url
    )

    $Command = [CdpCommandPage]::navigate($SessionId, $Url)
    $CdpPage = $Server.GetPageBySessionId($SessionId)
    $OldRuntimeUniqueId = $CdpPage.RuntimeUniqueId

    $Server.SendCommand($Command)

    if ($null -ne $OldRuntimeUniqueId) {
        while ($CdpPage.RuntimeUniqueId -eq $OldRuntimeUniqueId) {
            Start-Sleep -Milliseconds 50
        }
    }

    while ($CdpPage.IsLoading) {
        Start-Sleep -Milliseconds 50
    }

    if ($CdpPage.Frames.Count -eq 0) { return }
    while ([System.Linq.Enumerable]::Sum([int[]]@($CdpPage.Frames.Values.IsLoading)) -eq $CdpPage.Frames.Count) {
        Start-Sleep -Milliseconds 50
    }
}

function Invoke-CdpInputClickElement {
    <#
        .SYNOPSIS
        Finds and clicks with element in the center of the box. Clicks from the top left of the element when $TopLeft is switched on.
        .PARAMETER Selector
        Javascript that returns ONE node object
        For example:
        document.querySelectorAll('[name=q]')[0]
        .PARAMETER Click
        Number of times to left click the mouse
        .PARAMETER OffsetX
        Number of pixels to offset from the center of the element on the X axis
        .PARAMETER OffsetY
        Number of pixels to offset from the center of the element on the Y axis
        .PARAMETER TopLeft
        Clicks from the top left of the element instead of center. Offset x and y will be relative to this position instead.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [CdpServer]$Server,
        [Parameter(Mandatory)]
        [string]$SessionId,
        [Parameter(Mandatory)]
        [string]$Selector,
        [Parameter(ParameterSetName = 'Click')]
        [int]$Click = 0,
        [Parameter(ParameterSetName = 'Click')]
        [int]$OffsetX = 0,
        [Parameter(ParameterSetName = 'Click')]
        [int]$OffsetY = 0,
        [Parameter(ParameterSetName = 'Click')]
        [switch]$TopLeft
    )

    $CdpPage = $Server.GetPageBySessionId($SessionId)

    $Command = [CdpCommandRuntime]::evaluate($CdpPage.SessionId, $Selector)
    $Command.params.uniqueContextId = "$($CdpPage.RuntimeUniqueId)"
    $Response = $Server.SendCommand($Command, $true)
    $CdpPage.ObjectId = $Response.result.result.objectId

    if ($Click -le 0) { return }

    $Command = [CdpCommandDom]::describeNode($CdpPage.SessionId, $CdpPage.ObjectId)
    $Command.params.objectId = $CdpPage.ObjectId
    $Response = $Server.SendCommand($Command, $true)

    if ($Response.error) {
        throw ('Error describing node: {0}' -f $Response.error.message)
    }

    $CdpPage.Node = $Response.result.node

    $Command = [CdpCommandDom]::getBoxModel($CdpPage.SessionId, $CdpPage.ObjectId)
    $Command.params.objectId = $CdpPage.ObjectId
    $Response = $Server.SendCommand($Command, $true)
    $CdpPage.BoxModel = $Response.result.model

    if ($TopLeft) {
        $PixelX = $CdpPage.BoxModel.content[0] + $OffsetX
        $PixelY = $CdpPage.BoxModel.content[1] + $OffsetY
    } else {
        $PixelX = $CdpPage.BoxModel.content[0] + ($CdpPage.BoxModel.width / 2) + $OffsetX
        $PixelY = $CdpPage.BoxModel.content[1] + ($CdpPage.BoxModel.height / 2) + $OffsetY
    }

    $Command = [CdpCommandInput]::dispatchMouseEvent($CdpPage.SessionId, 'mousePressed', $PixelX, $PixelY, 'left')
    $Command.params.clickCount = $Click
    $Server.SendCommand($Command)
    $Command.params.type = 'mouseReleased'
    $Server.SendCommand($Command)
}

function Invoke-CdpInputSendKeys {
    <#
        .SYNOPSIS
        Sends keys to a session
        .PARAMETER Keys
        String to send
        .EXAMPLE
        Invoke-CdpInputSendKeys -Server $Server -SessionId $SessionId -Keys 'Hello World'
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [CdpServer]$Server,
        [Parameter(Mandatory)]
        [string]$SessionId,
        [Parameter(Mandatory)]
        [string]$Keys
    )

    $Command = [CdpCommandInput]::dispatchKeyEvent($SessionId, $null)
    $Keys.ToCharArray().ForEach({
            $Command.params.text = $_
            $Server.SendCommand($Command)
        }
    )
}