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 {
    # it's more dictionary now than property
    # did not want to use monitor.enter/exit
    [string]$TargetId
    [string]$Url
    [string]$Title
    [string]$BrowserContextId
    [int]$ProcessId

    CdpPage($TargetId, $Url, $Title, $BrowserContextId) {
        $this.TargetId = $TargetId
        $this.Url = $Url
        $this.Title = $Title
        $this.BrowserContextId = $BrowserContextId
        $this.TargetInfo.SessionId = $null

        $this.LoadingEvents.IsLoading = $false
        $this.LoadingEvents.DomContentEventFired = 0
        $this.LoadingEvents.LoadEventFired = 0
        $this.LoadingEvents.FrameStoppedLoading = 0
        $this.LoadingEvents.FrameStartedLoading = 0

        $this.PageInfo.RuntimeUniqueId = $null
        $this.PageInfo.ObjectId = $null
        $this.PageInfo.Node = $null
        $this.PageInfo.BoxModel = $null
    }

    [System.Collections.Concurrent.ConcurrentDictionary[string, object]]$TargetInfo = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()
    [System.Collections.Concurrent.ConcurrentDictionary[string, object]]$LoadingEvents = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()
    [System.Collections.Concurrent.ConcurrentDictionary[string, object]]$Frames = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()
    [System.Collections.Concurrent.ConcurrentDictionary[string, object]]$PageInfo = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()
}

class CdpFrame {
    $FrameId
    $ParentFrameId
    $SessionId
    $RuntimeUniqueId
    CdpFrame ($FrameId, $SessionId, $ParentFrameId) {
        $this.LoadingEvents.FrameStartedLoading = 0
        $this.LoadingEvents.FrameStoppedLoading = 0
        $this.LoadingEvents.IsLoading = $false
        $this.FrameId = $FrameId
        $this.ParentFrameId = $ParentFrameId
        $this.SessionId = $SessionId
        $this.RuntimeUniqueId = $null
    }
    CdpFrame ($FrameId, $SessionId) {
        $this.LoadingEvents.FrameStartedLoading = 0
        $this.LoadingEvents.FrameStoppedLoading = 0
        $this.LoadingEvents.IsLoading = $false
        $this.FrameId = $FrameId
        $this.ParentFrameId = $null
        $this.SessionId = $SessionId
        $this.RuntimeUniqueId = $null
    }
    # so far not needed.
    # $FrameInfo = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()
    $LoadingEvents = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()
}


# [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.LoadingEvents.AddOrUpdate('DomContentEventFired', 1, { param($Key, $OldValue) $OldValue + 1 })
        if ($CdpPage.LoadingEvents.LoadEventFired -eq $CdpPage.LoadingEvents.DomContentEventFired) {
            $CdpPage.LoadingEvents.AddOrUpdate('IsLoading', $false, { param($Key, $OldValue) $false })
        }

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

    hidden [void]FrameAttached($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.sessionId)
        $null = $CdpPage.Frames.GetOrAdd($Response.params.frameId, [CdpFrame]::new($Response.params.frameId, $Response.sessionId, $Response.params.parentFrameId))

        $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.LoadingEvents.AddOrUpdate('LoadEventFired', 1, { param($Key, $OldValue) $OldValue + 1 })
        if ($CdpPage.LoadingEvents.LoadEventFired -eq $CdpPage.LoadingEvents.DomContentEventFired) {
            $CdpPage.LoadingEvents.AddOrUpdate('IsLoading', $false, { param($Key, $OldValue) $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.LoadingEvents.AddOrUpdate('FrameStartedLoading', 1, { param($Key, $OldValue) $OldValue + 1 })
        } else {
            $Frame = $null
            while (!$CdpPage.Frames.TryGetValue($Response.params.frameId, [ref]$Frame)) {
                Start-Sleep -Milliseconds 50
            }
            $Frame.LoadingEvents.AddOrUpdate('FrameStartedLoading', 1, { param($Key, $OldValue) $OldValue + 1 })
            $Frame.LoadingEvents.AddOrUpdate('IsLoading', $true, { param($Key, $OldValue) $true })
            # 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.LoadingEvents.AddOrUpdate('IsLoading', $true, { param($Key, $OldValue) $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.LoadingEvents.AddOrUpdate('FrameStoppedLoading', 1, { param($Key, $OldValue) $OldValue + 1 })
        } else {
            $Frame = $null
            while (!$CdpPage.Frames.TryGetValue($Response.params.frameId, [ref]$Frame)) {
                Start-Sleep -Milliseconds 50
            }
            $Frame.LoadingEvents.AddOrUpdate('FrameStoppedLoading', 1, { param($Key, $OldValue) $OldValue + 1 })
            $Frame.LoadingEvents.AddOrUpdate('IsLoading', $false, { param($Key, $OldValue) $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, $Target.browserContextId)
        $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.TargetInfo.AddOrUpdate('SessionId', $Response.params.sessionId, { param($Key, $OldValue) $Response.params.sessionId })
        $null = $this.SharedState.Sessions.TryAdd($Response.params.sessionId, $CdpPage)

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

    hidden [void]DetachedFromTarget($Response) {
        $CdpPage = $this.GetPageBySessionId($Response.params.sessionId)
        $CdpPage.TargetInfo.AddOrUpdate('SessionId', $null, { param($Key, $OldValue) $null })
        $null = $this.SharedState.Sessions.TryRemove($Response.params.sessionId, [ref]$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)
        $CdpPage.PageInfo.AddOrUpdate('RuntimeUniqueId', $null, { param($Key, $OldValue) $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.PageInfo.AddOrUpdate('RuntimeUniqueId', $Response.params.context.uniqueId, { param($Key, $OldValue) $Response.params.context.uniqueId } )
        } else {
            $Frame = $CdpPage.Frames.GetOrAdd($FrameId, [CdpFrame]::new($FrameId, $Response.sessionId))
            $Frame.RuntimeUniqueId = $Response.params.context.uniqueId
        }

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

    [CdpPage]GetPageBySessionId([string]$SessionId) {
        $Page = $null
        while ($null -eq $Page) {
            if (!$this.SharedState.Sessions.TryGetValue($SessionId, [ref]$Page)) {
                Start-Sleep -Milliseconds 50
                # Write-Host ('getting targetid')
            }
        }
        return $Page
    }

    [CdpPage]GetPageByTargetId([string]$TargetId) {
        $Page = $null
        while ($null -eq $Page) {
            if (!$this.SharedState.Targets.TryGetValue($TargetId, [ref]$Page)) {
                Start-Sleep -Milliseconds 50
                # Write-Host ('getting targetid')
            }
        }
        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.Sessions = [System.Collections.Concurrent.ConcurrentDictionary[string, CdpPage]]::new()
        $this.SharedState.Callbacks = [System.Collections.Generic.Dictionary[string, scriptblock]]::new()
        $this.SharedState.BrowserContexts = [System.Collections.Generic.List[string]]::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)) {
                                Start-Sleep -Milliseconds 50
                            }
                        }

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

                                if (!$SucessfullyAdded) {
                                    Start-Sleep -Milliseconds 50
                                }
                            } 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
        while ($null -eq $Page) {
            if (!$this.SharedState.Sessions.TryGetValue($SessionId, [ref]$Page)) {
                Start-Sleep -Milliseconds 50
                # Write-Host ('server getting sessionid')
            }
        }
        return $Page
    }

    [CdpPage]GetPageByTargetId([string]$TargetId) {
        $Page = $null
        while ($null -eq $Page) {
            if (!$this.SharedState.Targets.TryGetValue($TargetId, [ref]$Page)) {
                Start-Sleep -Milliseconds 50
                # Write-Host ('server getting sessionid')
            }
        }
        return $Page
    }

    [void]SendRuntimeEvaluate([string]$SessionId, [string]$Expression) {
        $JsonCommand = [CdpCommandRuntime]::evaluate($SessionId, $Expression)
        $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
        }

        $SessionId = $null
        while ($null -eq $SessionId) {
            $null = $this.SharedState.Targets.Values[0].TargetInfo.TryGetValue('SessionId', [ref]$SessionId)
            Start-Sleep -Milliseconds 50
        }

        $JsonCommand = [CdpCommandPage]::enable($SessionId)
        $this.SendCommand($JsonCommand)
        $JsonCommand = [CdpCommandRuntime]::enable($SessionId)
        $this.SendCommand($JsonCommand, $true)

        # $this.SendPageEnable($SessionId)
        # $this.SendRuntimeEnable($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]bringToFront($SessionId) {
        return @{
            method = 'Page.bringToFront'
            sessionId = $SessionId
        }
    }
    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]createBrowserContext() {
        return @{
            method = 'Target.createBrowserContext'
            params = @{
                disposeOnDetach = $true
            }
        }
    }
    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()
    }

    $Server.SharedState.BrowserContexts.Add($Server.SharedState.Targets.Values[0].BrowserContextId)

    $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',
        [Parameter(ParameterSetName = 'Tab')]
        [string]$BrowserContextId,
        [Parameter(ParameterSetName = 'NewWindow')]
        [switch]$NewWindow
    )

    if ($NewWindow) {
        $Command = [CdpCommandTarget]::createBrowserContext()
        $Response = $Server.SendCommand($Command, $true)
        $Server.SharedState.BrowserContexts.Add($Response.result.browserContextId)
    }

    $Command = [CdpCommandTarget]::createTarget($Url)
    if ($NewWindow) {
        $Command.params.newWindow = $true
        $Command.params.browserContextId = $Response.result.browserContextId
    } else {
        $Command.params.browserContextId = $BrowserContextId #$Server.SharedState.BrowserContexts[$BrowserContextIndex]
    }
    $Response = $Server.SendCommand($Command, $true)

    $CdpPage = $Server.GetPageByTargetId($Response.result.targetId)
    $SessionId = $null
    while ($null -eq $SessionId) {
        $null = $CdpPage.TargetInfo.TryGetValue('SessionId', [ref]$SessionId)
    }

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

    $RuntimeUniqueId = $null
    while ($null -eq $RuntimeUniqueId) {
        $null = $CdpPage.PageInfo.TryGetValue('RuntimeUniqueId', [ref]$RuntimeUniqueId)
    }

    $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.PageInfo.RuntimeUniqueId

    $Server.SendCommand($Command)

    $NewRuntimeUniqueId = $null
    $null = $CdpPage.PageInfo.TryGetValue('RuntimeUniqueId', [ref]$NewRuntimeUniqueId)
    if ($null -ne $OldRuntimeUniqueId) {
        while ($NewRuntimeUniqueId -eq $OldRuntimeUniqueId) {
            Start-Sleep -Milliseconds 50
            $null = $CdpPage.PageInfo.TryGetValue('RuntimeUniqueId', [ref]$NewRuntimeUniqueId)
        }
    }

    $IsLoading = $null
    $null = $CdpPage.LoadingEvents.TryGetValue('IsLoading', [ref]$IsLoading)
    while ($IsLoading) {
        Start-Sleep -Milliseconds 50
        $null = $CdpPage.LoadingEvents.TryGetValue('IsLoading', [ref]$IsLoading)
    }

    if ($CdpPage.Frames.Count -eq 0) { return }
    while ([System.Linq.Enumerable]::Sum([int[]]@($CdpPage.Frames.Values.LoadingEvents.IsLoading)) -gt 0) {
        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.TargetInfo.SessionId, $Selector)
    $Command.params.uniqueContextId = "$($CdpPage.PageInfo.RuntimeUniqueId)"
    $Response = $Server.SendCommand($Command, $true)
    $CdpPage.PageInfo.ObjectId = $Response.result.result.objectId

    if ($Click -le 0) { return }

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

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

    $CdpPage.PageInfo.Node = $Response.result.node

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

    $Command = [CdpCommandPage]::bringToFront($CdpPage.TargetInfo.SessionId)
    $Response = $Server.SendCommand($Command, $true)

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

    $Command = [CdpCommandInput]::dispatchMouseEvent($CdpPage.TargetInfo.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)
        }
    )
}