diff --git a/.github/workflows/BuildWebSocket.yml b/.github/workflows/BuildWebSocket.yml index 2f67fd1..dd87ef2 100644 --- a/.github/workflows/BuildWebSocket.yml +++ b/.github/workflows/BuildWebSocket.yml @@ -103,7 +103,7 @@ jobs: } } @Parameters - name: PublishTestResults - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@main with: name: PesterResults path: '**.TestResults.xml' diff --git a/Build/GitHub/Steps/PublishTestResults.psd1 b/Build/GitHub/Steps/PublishTestResults.psd1 new file mode 100644 index 0000000..e8111e8 --- /dev/null +++ b/Build/GitHub/Steps/PublishTestResults.psd1 @@ -0,0 +1,10 @@ +@{ + name = 'PublishTestResults' + uses = 'actions/upload-artifact@main' + with = @{ + name = 'PesterResults' + path = '**.TestResults.xml' + } + if = '${{always()}}' +} + diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eacffa..5403c61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,41 @@ > Like It? [Star It](https://github.com/PowerShellWeb/WebSocket) > Love It? [Support It](https://github.com/sponsors/StartAutomating) +## WebSocket 0.1.3 + +WebSocket server support! + +### Server Features + +For consistency, capabilities, and aesthetics, +this is a fairly fully features HTTP server that happens to support websockets + +* `Get-WebSocket` `-RootURL/-HostHeader` ( #47 ) +* `-StyleSheet` lets you include stylesheets ( #64 ) +* `-JavaScript` lets you include javascript ( #65 ) +* `-Timeout/-LifeSpan` server support ( #85 ) + +### Client Improvements + +* `Get-WebSocket -QueryParameter` lets you specify query parameters ( #41 ) +* `Get-WebSocket -Debug` lets you debug the websocketjob. ( #45 ) +* `Get-WebSocket -SubProtocol` lets you specify a subprotocol (defaults to JSON) ( #46 ) +* `Get-WebSocket -Authenticate` allows sends pre-connection authentication ( #69 ) +* `Get-WebSocket -Handshake` allows post-connection authentication ( #81 ) +* `Get-WebSocket -Force` allows the creation of multiple clients ( #58 ) + + +### General Improvements + +* `Get-WebSocket -SupportsPaging` ( #55 ) +* `Get-WebSocket -BufferSize 64kb` ( #52 ) +* `Get-WebSocket -Force` ( #58 ) +* `Get-WebSocket -Filter` ( #42 ) +* `Get-WebSocket -ForwardEvent` ( #56 ) +* `Get-WebSocket` Parameter Sets ( #73, #74 ) +* `-Broadcast` lets you broadcast to clients and servers ( #39 ) +* `Get-WebSocket` quieting previous job check ( #43 ) + ## WebSocket 0.1.2 * WebSocket now decorates (#34) diff --git a/Commands/Get-WebSocket.ps1 b/Commands/Get-WebSocket.ps1 index a2635c8..8216fd9 100644 --- a/Commands/Get-WebSocket.ps1 +++ b/Commands/Get-WebSocket.ps1 @@ -8,17 +8,26 @@ function Get-WebSocket { This will create a job that connects to a WebSocket and outputs the results. If the `-Watch` parameter is provided, will output a continous stream of objects. + .LINK + https://websocket.powershellweb.com/Get-WebSocket/ + .LINK + https://learn.microsoft.com/en-us/dotnet/api/system.net.websockets.clientwebsocket?wt.mc_id=MVP_321542 + .LINK + https://learn.microsoft.com/en-us/dotnet/api/system.net.httplistener?wt.mc_id=MVP_321542 .EXAMPLE # Create a WebSocket job that connects to a WebSocket and outputs the results. - Get-WebSocket -WebSocketUri "wss://localhost:9669/" + $socketServer = Get-WebSocket -RootUrl "http://localhost:8387/" -HTML "

WebSocket Server

" + $socketClient = Get-WebSocket -SocketUrl "ws://localhost:8387/" + foreach ($n in 1..10) { $socketServer.Send(@{n=Get-Random}) } + $socketClient | Receive-Job -Keep .EXAMPLE # Get is the default verb, so we can just say WebSocket. # `-Watch` will output a continous stream of objects from the websocket. - # For example, let's Watch BlueSky, but just the text. - websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Watch | + # For example, let's Watch BlueSky, but just the text + websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Watch -Maximum 1kb | % { $_.commit.record.text - } + } .EXAMPLE # Watch BlueSky, but just the text and spacing $blueSkySocketUrl = "wss://jetstream2.us-$( @@ -27,12 +36,19 @@ function Get-WebSocket { "wantedCollections=app.bsky.feed.post" ) -join '&')" websocket $blueSkySocketUrl -Watch | - % { Write-Host "$(' ' * (Get-Random -Max 10))$($_.commit.record.text)$($(' ' * (Get-Random -Max 10)))"} - .EXAMPLE + % { Write-Host "$(' ' * (Get-Random -Max 10))$($_.commit.record.text)$($(' ' * (Get-Random -Max 10)))"} -Max 1kb + .EXAMPLE + # Watch continuously in a background job. websocket wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post + .EXAMPLE + # Watch the first message in -Debug mode. + # This allows you to literally debug the WebSocket messages as they are encountered. + websocket wss://jetstream2.us-west.bsky.network/subscribe -QueryParameter @{ + wantedCollections = 'app.bsky.feed.post' + } -Max 1 -Debug .EXAMPLE # Watch BlueSky, but just the emoji - websocket jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Tail | + websocket jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Tail -Max 1kb | Foreach-Object { $in = $_ if ($in.commit.record.text -match '[\p{IsHighSurrogates}\p{IsLowSurrogates}]+') { @@ -60,11 +76,13 @@ function Get-WebSocket { } .EXAMPLE # BlueSky, but just the hashtags - websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -WatchFor @{ + websocket wss://jetstream2.us-west.bsky.network/subscribe -QueryParameter @{ + wantedCollections = 'app.bsky.feed.post' + } -WatchFor @{ {$webSocketoutput.commit.record.text -match "\#\w+"}={ $matches.0 } - } + } -Maximum 1kb .EXAMPLE # BlueSky, but just the hashtags (as links) websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -WatchFor @{ @@ -104,24 +122,112 @@ function Get-WebSocket { Sort Count -Descending | Select -First 10 #> - [CmdletBinding(PositionalBinding=$false)] - [Alias('WebSocket')] + [CmdletBinding( + PositionalBinding=$false, + SupportsPaging, + DefaultParameterSetName='WebSocketClient' + )] + [Alias('WebSocket','ws','wss')] param( - # The Uri of the WebSocket to connect to. - [Parameter(Position=0,ValueFromPipelineByPropertyName)] - [Alias('Url','Uri')] - [uri]$WebSocketUri, + # The WebSocket Uri. + [Parameter(Position=0,ParameterSetName='WebSocketClient',ValueFromPipelineByPropertyName)] + [Alias('Url','Uri','WebSocketUri','WebSocketUrl')] + [uri] + $SocketUrl, + + # One or more root urls. + # If these are provided, a WebSocket server will be created with these listener prefixes. + [Parameter(Mandatory,ValueFromPipelineByPropertyName,ParameterSetName='WebSocketServer')] + [Alias('HostHeader','Host','CNAME','ListenerPrefix','ListenerPrefixes','ListenerUrl')] + [string[]] + $RootUrl, + + # A route table for all requests. + [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='WebSocketServer')] + [Alias('Routes','RouteTable','WebHook','WebHooks')] + [Collections.IDictionary] + $Route, + + # The Default HTML. + # This will be displayed when visiting the root url. + [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='WebSocketServer')] + [Alias('DefaultHTML','Home','Index','IndexHTML','DefaultPage')] + [string] + $HTML, + + # The name of the palette to use. This will include the [4bitcss](https://4bitcss.com) stylesheet. + [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='WebSocketServer')] + [Alias('Palette','ColorScheme','ColorPalette')] + [ArgumentCompleter({ + param ($commandName,$parameterName,$wordToComplete,$commandAst,$fakeBoundParameters ) + if (-not $script:4bitcssPaletteList) { + $script:4bitcssPaletteList = Invoke-RestMethod -Uri https://cdn.jsdelivr.net/gh/2bitdesigns/4bitcss@latest/docs/Palette-List.json + } + if ($wordToComplete) { + $script:4bitcssPaletteList -match "$([Regex]::Escape($wordToComplete) -replace '\\\*', '.{0,}')" + } else { + $script:4bitcssPaletteList + } + })] + [string] + $PaletteName, - # A ScriptBlock that will handle the output of the WebSocket. + # The [Google Font](https://fonts.google.com/) name. + [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='WebSocketServer')] + [Alias('FontName')] + [string] + $GoogleFont, + + # The Google Font name to use for code blocks. + # (this should be a [monospace font](https://fonts.google.com/?classification=Monospace)) + [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='WebSocketServer')] + [Alias('PreFont','CodeFontName','PreFontName')] + [string] + $CodeFont, + + # A list of javascript files or urls to include in the content. + [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='WebSocketServer')] + [string[]] + $JavaScript, + + # A javascript import map. This allows you to import javascript modules. + [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='WebSocketServer')] + [Alias('ImportsJavaScript','JavaScriptImports','JavaScriptImportMap')] + [Collections.IDictionary] + $ImportMap, + + # A collection of query parameters. + # These will be appended onto the `-SocketUrl`. + # Multiple values for a single parameter will be passed as multiple parameters. + [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='WebSocketClient')] + [Alias('QueryParameters','Query')] + [Collections.IDictionary] + $QueryParameter, + + # A ScriptBlock that can handle the output of the WebSocket or the Http Request. + # This may be run in a separate `-Runspace` or `-RunspacePool`. + # The output of the WebSocket or the Context will be passed as an object. [ScriptBlock] $Handler, + # If set, will forward websocket messages as events. + # Only events that match -Filter will be forwarded. + [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='WebSocketClient')] + [Alias('Forward')] + [switch] + $ForwardEvent, + # Any variables to declare in the WebSocket job. # These variables will also be added to the job as properties. - [Collections.IDictionary] + [Collections.IDictionary] $Variable = @{}, - # The name of the WebSocket job. + # Any Http Headers to include in the WebSocket request or server response. + [Collections.IDictionary] + [Alias('Headers')] + $Header, + + # The name of the WebSocket job. [string] $Name, @@ -130,51 +236,109 @@ function Get-WebSocket { $InitializationScript = {}, # The buffer size. Defaults to 16kb. + [Parameter(ValueFromPipelineByPropertyName)] [int] - $BufferSize = 16kb, + $BufferSize = 64kb, + + # If provided, will send an object. + # If this is a scriptblock, it will be run and the output will be sent. + [Alias('Send')] + [PSObject] + $Broadcast, # The ScriptBlock to run after connection to a websocket. # This can be useful for making any initial requests. + [Parameter(ParameterSetName='WebSocketClient')] [ScriptBlock] $OnConnect, # The ScriptBlock to run when an error occurs. + [Parameter(ParameterSetName='WebSocketClient')] [ScriptBlock] $OnError, # The ScriptBlock to run when the WebSocket job outputs an object. + [Parameter(ParameterSetName='WebSocketClient')] [ScriptBlock] $OnOutput, # The Scriptblock to run when the WebSocket job produces a warning. + [Parameter(ParameterSetName='WebSocketClient')] [ScriptBlock] $OnWarning, + # If provided, will authenticate the WebSocket. + # Many websockets require an initial authentication handshake + # after an initial message is received. + # This parameter can be either a ScriptBlock or any other object. + # If it is a ScriptBlock, it will be run with the output of the WebSocket passed as the first argument. + # This will run after the socket is connected but before any messages are received. + [Parameter(ParameterSetName='WebSocketClient')] + [Alias('Authorize','HelloMessage')] + [PSObject] + $Authenticate, + + # If provided, will shake hands after the first websocket message is received. + # This parameter can be either a ScriptBlock or any other object. + # If it is a ScriptBlock, it will be run with the output of the WebSocket passed as the first argument. + # This will run after the socket is connected and the first message is received. + [Parameter(ParameterSetName='WebSocketClient')] + [Alias('Identify')] + [PSObject] + $Handshake, + # If set, will watch the output of the WebSocket job, outputting results continuously instead of outputting a websocket job. + [Parameter(ParameterSetName='WebSocketClient')] [Alias('Tail')] [switch] $Watch, # If set, will output the raw text that comes out of the WebSocket. + [Parameter(ParameterSetName='WebSocketClient')] [Alias('Raw')] [switch] $RawText, # If set, will output the raw bytes that come out of the WebSocket. + [Parameter(ParameterSetName='WebSocketClient')] [Alias('RawByte','RawBytes','Bytes','Byte')] [switch] - $Binary, + $Binary, + + # If set, will force a new job to be created, rather than reusing an existing job. + [switch] + $Force, + + # The subprotocol used by the websocket. If not provided, this will default to `json`. + [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='WebSocketClient')] + [string] + $SubProtocol, + + # If set, will not set a subprotocol. This will only work with certain websocket servers, but will not work with an HTTP Listener WebSocket. + [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='WebSocketClient')] + [switch] + $NoSubProtocol, + + # One or more filters to apply to the output of the WebSocket. + # These can be strings, regexes, scriptblocks, or commands. + # If they are strings or regexes, they will be applied to the raw text. + # If they are scriptblocks, they will be applied to the deserialized JSON. + # These filters will be run within the WebSocket job. + [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='WebSocketClient')] + [PSObject[]] + $Filter, # If set, will watch the output of a WebSocket job for one or more conditions. # The conditions are the keys of the dictionary, and can be a regex, a string, or a scriptblock. # The values of the dictionary are what will happen when a match is found. + [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='WebSocketClient')] [ValidateScript({ $keys = $_.Keys $values = $_.values foreach ($key in $keys) { if ($key -isnot [scriptblock]) { - throw "Keys '$key' must be a scriptblock" - } + throw "Key '$key' must be a scriptblock" + } } foreach ($value in $values) { if ($value -isnot [scriptblock] -and $value -isnot [string]) { @@ -187,145 +351,935 @@ function Get-WebSocket { [Collections.IDictionary] $WatchFor, - # The timeout for the WebSocket connection. If this is provided, after the timeout elapsed, the WebSocket will be closed. + # The timeout for the WebSocket connection. + # If this is provided, after the timeout elapsed, the WebSocket will be closed. + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('Lifespan')] [TimeSpan] $TimeOut, # If provided, will decorate the objects outputted from a websocket job. # This will only decorate objects converted from JSON. + [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='WebSocketClient')] [Alias('PSTypeNames','Decorate','Decoration')] [string[]] $PSTypeName, # The maximum number of messages to receive before closing the WebSocket. + [Parameter(ValueFromPipelineByPropertyName)] [long] $Maximum, + # The throttle limit used when creating background jobs. + [Parameter(ValueFromPipelineByPropertyName)] + [int] + $ThrottleLimit = 64, + # The maximum time to wait for a connection to be established. - # By default, this is 7 seconds. + # By default, this is 7 seconds. + [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='WebSocketClient')] [TimeSpan] $ConnectionTimeout = '00:00:07', # The Runspace where the handler should run. # Runspaces allow you to limit the scope of the handler. + [Parameter(ValueFromPipelineByPropertyName)] [Runspace] $Runspace, # The RunspacePool where the handler should run. # RunspacePools allow you to limit the scope of the handler to a pool of runspaces. + [Parameter(ValueFromPipelineByPropertyName)] [Management.Automation.Runspaces.RunspacePool] [Alias('Pool')] $RunspacePool ) begin { - $SocketJob = { - param([Collections.IDictionary]$Variable) + $SocketClientJob = { + param( + # By accepting a single parameter containing variables, + # we can avoid the need to pass in a large number of parameters. + # we can also modify this dictionary, to provide a way to pass information back. + [Collections.IDictionary]$Variable + ) + $Variable.JobRunspace = [Runspace]::DefaultRunspace + + # Take every every `-Variable` passed in and define it within the job foreach ($keyValue in $variable.GetEnumerator()) { $ExecutionContext.SessionState.PSVariable.Set($keyValue.Key, $keyValue.Value) - } + } - if ((-not $WebSocketUri) -or $webSocket) { - throw "No WebSocketUri" + # If we have no socket url, + if ((-not $SocketUrl)) { + # throw up an error. + throw "No SocketUrl" } - if (-not $WebSocketUri.Scheme) { - $WebSocketUri = [uri]"wss://$WebSocketUri" - } + # If the socket url does not have a scheme + if (-not $SocketUrl.Scheme) { + # assume `wss` + $SocketUrl = [uri]"wss://$SocketUrl" + } elseif ( + # otherwise, if the scheme is http or https + $SocketUrl.Scheme -match '^https?' + ) { + # replace it with `ws` or `wss` + $SocketUrl = $SocketUrl -replace '^http', 'ws' + } + + # If any query parameters were provided + if ($QueryParameter) { + # add them to the socket url + $SocketUrl = [uri]"$($SocketUrl)$($SocketUrl.Query ? '&' : '?')$(@( + foreach ($keyValuePair in $QueryParameter.GetEnumerator()) { + # cannocially, each key value pair should be url encoded, + # and multiple values should be passed multiple times. + foreach ($value in $keyValuePair.Value) { + $valueString = + # If the value is a boolean or a switch, + if ($value -is [bool] -or $value -is [switch]) { + # convert it to a string and make it lowercase. + ($value -as [bool] -as [string]).ToLower() + } else { + # Otherwise, just stringify. + "$value" + } + "$($keyValuePair.Key)=$([Web.HttpUtility]::UrlEncode($valueString).Replace('+', '%20'))" + } + }) -join '&')" + } + # If we had not set a -BufferSize, if (-not $BufferSize) { - $BufferSize = 16kb + $BufferSize = 64kb # default to 64kb. } + # Create a cancellation token, as this will save syntax space $CT = [Threading.CancellationToken]::None - if (-not $webSocket) { + # If `$WebSocket `is not already a websocket + if ($webSocket -isnot [Net.WebSockets.ClientWebSocket]) { + # create a new socket $ws = [Net.WebSockets.ClientWebSocket]::new() - $null = $ws.ConnectAsync($WebSocketUri, $CT).Wait() + if ($SubProtocol) { + # and add the subprotocol + $ws.Options.AddSubProtocol($SubProtocol) + } elseif (-not $NoSubProtocol) { + $ws.Options.AddSubProtocol('json') + } + # If there are headers + if ($Header) { + # add them to the initial socket request. + foreach ($headerKeyValue in $header.GetEnumerator()) { + $ws.Options.SetRequestHeader($headerKeyValue.Key, $headerKeyValue.Value) + } + } + # Now, let's try to connect to the WebSocket. + $null = $ws.ConnectAsync($SocketUrl, $CT).Wait() } else { $ws = $WebSocket - } + } + # Keep track of the time $webSocketStartTime = $Variable.WebSocketStartTime = [DateTime]::Now + # and add the WebSocket to the variable dictionary, so we can access it later. $Variable.WebSocket = $ws - $MessageCount = [long]0 - - while ($true) { - if ($ws.State -ne 'Open') {break } + # Initialize some counters: + $MessageCount = [long]0 # * The number of messages received + $FilteredCount = [long]0 # * The number of messages filtered out + $SkipCount = [long]0 # * The number of messages skipped + + # Initialize variables related to handshaking + $saidHello = $null # * Whether we have said hello + $shookHands = $null # * Whether we have shaken hands + + # This loop will run as long as the websocket is open. + :WebSocketMessageLoop while ($ws.State -eq 'Open') { + # If we've given a timeout for the websocket, + # and the websocket has been open for longer than the timeout, if ($TimeOut -and ([DateTime]::Now - $webSocketStartTime) -gt $TimeOut) { + # then it's closing time (you don't have to go home but you can't stay here). $ws.CloseAsync([Net.WebSockets.WebSocketCloseStatus]::NormalClosure, 'Timeout', $CT).Wait() break } - if ($Maximum -and $MessageCount -ge $Maximum) { + # If we've gotten the maximum number of messages, + if ($Maximum -and ( + ($MessageCount - $FilteredCount) -ge $Maximum + )) { + # then I can't even take any more responses. $ws.CloseAsync([Net.WebSockets.WebSocketCloseStatus]::NormalClosure, 'Maximum messages reached', $CT).Wait() break } + # If we're authenticating, and haven't yet said hello + if ($Authenticate -and -not $SaidHello) { + # then we should say hello. + # Determine the authentication message + $authenticationMessage = + # If the authentication message is a scriptblock, + if ($Authenticate -is [ScriptBlock]) { + & $Authenticate # run it + } else { + $authenticate # otherwise, use it as-is. + } + + # If we have an authentication message + if ($authenticationMessage) { + # and it's not a string + if ($authenticationMessage -isnot [string]) { + # then we should send it as JSON and mark that we've said hello. + $saidHello = $ws.SendAsync([ArraySegment[byte]]::new( + $OutputEncoding.GetBytes((ConvertTo-Json -InputObject $authenticationMessage -Depth 10)) + ), 'Text', $true, $CT) + } + } + } + + # Ok, let's get the next message. $Buf = [byte[]]::new($BufferSize) $Seg = [ArraySegment[byte]]::new($Buf) - $null = $ws.ReceiveAsync($Seg, $CT).Wait() + $receivingWebSocket = $ws.ReceiveAsync($Seg, $CT) + # use this tight loop to let us cancel the await if we need to. + while (-not ($receivingWebSocket.IsCompleted -or $receivingWebSocket.IsFaulted -or $receivingWebSocket.IsCanceled)) { + + } + # If we had a problem, write an error. + if ($receivingWebSocket.Exception) { + Write-Error -Exception $receivingWebSocket.Exception -Category ProtocolError + continue + } $MessageCount++ try { + # If we have a handshake and we haven't yet shaken hands + if ($Handshake -and -not $shookHands) { + # then we should shake hands. + # Get the message string + $messageString = $OutputEncoding.GetString($Buf, 0, $Buf.Count) + # and try to convert it from JSON. + $messageObject = ConvertFrom-Json -InputObject $messageString *>&1 + # Determine the handshake message + $handShakeMessage = + # If the handshake message is a scriptblock, + if ($Handshake -is [ScriptBlock]) { + & $Handshake $MessageObject # run it and pass the message + } else { + $Handshake # otherwise, use it as-is. + } + + # If we have a handshake message + if ($handShakeMessage) { + # and it's not a string + if ($handShakeMessage -isnot [string]) { + # then we should send it as JSON and mark that we've shaken hands. + $saidHello = $ws.SendAsync([ArraySegment[byte]]::new( + $OutputEncoding.GetBytes((ConvertTo-Json -InputObject $handShakeMessage -Depth 10)) + ), 'Text', $true, $CT) + } + } + } + + # Get the message from the websocket $webSocketMessage = - if ($Binary) { - $Buf -gt 0 - } elseif ($RawText) { - $OutputEncoding.GetString($Buf, 0, $Buf.Count) + if ($Binary) { # If we wanted binary + $Buf -gt 0 -as [byte[]] # then return non-null bytes } else { - $JS = $OutputEncoding.GetString($Buf, 0, $Buf.Count) - if ([string]::IsNullOrWhitespace($JS)) { continue } - ConvertFrom-Json $JS + # otherwise, get the message as a string + $messageString = $OutputEncoding.GetString($Buf, 0, $Buf.Count) + # if we have any filters + if ($Filter) { + # then we see if we can apply them now. + foreach ($fil in $filter) { + # Wilcard filters can be applied to the raw text + if ($fil -is [string] -and $messageString -like "*$fil*") { + $FilteredCount++ + continue WebSocketMessageLoop + } + # and so can regex filters. + if ($fil -is [regex] -and $fil.IsMatch($messageString)) { + $FilteredCount++ + continue WebSocketMessageLoop + } + } + } + # If we have asked for -RawText + if ($RawText) { + $messageString # then return the raw text + } else { + # Otherwise, try to convert the message from JSON. + $MessageObject = ConvertFrom-Json -InputObject $messageString + + # Now we can run any filters that are scriptblocks or commands. + if ($filter) { + foreach ($fil in $Filter) { + if ($fil -is [ScriptBlock] -or + $fil -is [Management.Automation.CommandInfo] + ) { + # Capture the output of the filter + $filterOutput = $MessageObject | & $fil $MessageObject + # if the output was falsy, + if (-not $filterOutput) { + $FilteredCount++ # filter out the message. + continue WebSocketMessageLoop + } + } + } + } + + # If -Skip was provided and we haven't skipped enough messages + if ($Skip -and ($SkipCount -le $Skip)) { + # then skip this message. + $SkipCount++ + continue WebSocketMessageLoop + } + + # Now, emit the message object. + # (expressions that are not assigned will be outputted) + $MessageObject + + # If we have a -First parameter, and we have not yet reached the maximum + # (after accounting for skips and filters) + if ($First -and ($MessageCount - $FilteredCount - $SkipCount) -ge $First) { + # then set the maximum to first (which will cancel this after the next loop) + $Maximum = $first + } + } } + + # If we want to decorate the output if ($PSTypeName) { + # clear it's typenames $webSocketMessage.pstypenames.clear() - [Array]::Reverse($PSTypeName) - foreach ($psType in $psTypeName) { - $webSocketMessage.pstypenames.add($psType) - } + for ($typeNameIndex = $PSTypeName.Length - 1; $typeNameIndex -ge 0; $typeNameIndex--) { + # and add each type name in reverse order + $webSocketMessage.pstypenames.add($PSTypeName[$typeNameIndex]) + } } - if ($handler) { - $psCmd = + + # If we are forwarding events + if ($ForwardEvent -and $MainRunspace.Events.GenerateEvent) { + # generate an event in the main runspace + $null = $MainRunspace.Events.GenerateEvent( + "$SocketUrl", + $ws, + @($webSocketMessage), + $webSocketMessage + ) + } + + # If we have an output handler, try to run it and get the output + $handledResponse = if ($handler) { + # We may need to run the handler in a `[PowerShell]` command. + $psCmd = + # This is true if we want `NoLanguage` mode. if ($runspace.LanguageMode -eq 'NoLanguage' -or $runspacePool.InitialSessionState.LanguageMode -eq 'NoLanguage') { + # (in which case we'll call .GetPowerShell()) $handler.GetPowerShell() - } elseif ($Runspace -or $RunspacePool) { - [PowerShell]::Create().AddScript($handler) + } elseif ( + # or if we have a runspace or runspace pool + $Runspace -or $RunspacePool + ) { + # (in which case we'll `.Create()` and `.AddScript()`) + [PowerShell]::Create().AddScript($handler, $true) } if ($psCmd) { + # If we have a runspace, we'll use that. if ($Runspace) { $psCmd.Runspace = $Runspace } elseif ($RunspacePool) { + # or, alternatively, we can use a runspace pool. $psCmd.RunspacePool = $RunspacePool } + # Now, we can invoke the command. + $psCmd.Invoke(@($webSocketMessage)) } else { + # Otherwise, we'll just run the handler. $webSocketMessage | . $handler } - + } + + # If we have a response from the handler, + if ($handledResponse) { + $handledResponse # emit that response. } else { - $webSocketMessage + $webSocketMessage # otherwise, emit the message. } } catch { Write-Error $_ } } - } + + # Now that the socket is closed, + # check for a status description. + # If there is one, + if ($ws.CloseStatusDescription) { + # write an error. + Write-Error $ws.CloseStatusDescription -TargetObject $ws + } + } + $SocketServerJob = { + <# + .SYNOPSIS + A fairly simple WebSocket server + .DESCRIPTION + A fairly simple WebSocket server + #> + param( + # By accepting a single parameter containing variables, + # we can avoid the need to pass in a large number of parameters. + # we can also modify this dictionary, to provide a way to pass information back. + [Collections.IDictionary]$Variable + ) + + # Take every every `-Variable` passed in and define it within the job + foreach ($keyValue in $variable.GetEnumerator()) { + $ExecutionContext.SessionState.PSVariable.Set($keyValue.Key, $keyValue.Value) + } + + $Variable['JobRunspace'] = [Runspace]::DefaultRunspace + + # If we have routes, we will cache all of their possible parameters now + if ($route.Count) { + # We want to keep the parameter sets + $routeParameterSets = [Ordered]@{} + # and the metadata about parameters. + $routeParameters = [Ordered]@{} + + # For each key and value in the route table, we will try to get the command info for the value. + foreach ($routePair in $route.GetEnumerator()) { + $routeToCmd = + # If the value is a scriptblock + if ($routePair.Value -is [ScriptBlock]) { + # we have to create a temporary function + $function:TempFunction = $routePair.Value + # and get that function. + $ExecutionContext.SessionState.InvokeCommand.GetCommand('TempFunction', 'Function') + } elseif ($routePair.Value -is [Management.Automation.CommandInfo]) { + $routePair.Value + } + if ($routeToCmd) { + $routeParameterSets[$routePair.Name] = $routeToCmd.ParametersSets + $routeParameters[$routePair.Name] = $routeToCmd.Parameters + } + } + } + + # If there's no listener, create one. + if (-not $httpListener) { + $httpListener = $variable['HttpListener'] = [Net.HttpListener]::new() + } + + # If the listener doesn't have a lookup table for SocketRequests, create one. + if (-not $httpListener.SocketRequests) { + $httpListener.psobject.properties.add( + [psnoteproperty]::new('SocketRequests', [Ordered]@{}), $true) + } + + # If the listener isn't listening, start it. + if (-not $httpListener.IsListening) { $httpListener.Start() } + + $variable['SiteHeader'] = $siteHeader = @( + + if ($Javascript) { + # as well as any javascript files provided. + foreach ($js in $Javascript) { + if ($js -match '.js$') { + "" + } else { + "" + } + } + } + + # If an import map was provided, we will include it. + if ($ImportMap) { + $variable['ImportMap'] = @( + "" + ) -join [Environment]::NewLine + } + + # If a palette name was provided, we will include the 4bitcss stylesheet. + if ($PaletteName) { + if ($PaletteName -match '/.+?\.css$') { + "" + + } else { + '' -replace '\.css', "$PaletteName.css" + } + } + + # If a font name was provided, we will include the font stylesheet. + if ($GoogleFont) { + "" + "" + } + + # If a code font was provided, we will include the code font stylesheet. + if ($CodeFont) { + "" + "" + } + + # and if any stylesheets were provided, we will include them. + foreach ($css in $variable.StyleSheet) { + if ($css -match '.css$') { + "" + } else { + "" + } + } + ) + + $httpListener.psobject.properties.add([psnoteproperty]::new('JobVariable',$Variable), $true) + $listenerStartTime = [DateTime]::Now + + # While the listener is listening, + while ($httpListener.IsListening) { + # If we've given a timeout for the listener, + # and the listener has been open for longer than the timeout, + if ($Timeout -and ([DateTime]::Now - $listenerStartTime) -gt $TimeOut) { + # then it's closing time (you don't have to go home but you can't stay here). + $httpListener.Stop() + break + } + # get the context asynchronously. + $contextAsync = $httpListener.GetContextAsync() + # and wait for it to complete. + while (-not ($contextAsync.IsCompleted -or $contextAsync.IsFaulted -or $contextAsync.IsCanceled)) { + # while this is going on, other events can be processed, and CTRL-C can exit. + # also, we can go ahead and check for any socket requests, and get ready for the next one if we find one. + foreach ($socketRequest in @($httpListener.SocketRequests.GetEnumerator())) { + if ($socketRequest.Value.Receiving.IsCompleted) { + $socketRequest.Value.MessageCount++ + $jsonMessage = ConvertFrom-Json -InputObject ($OutputEncoding.GetString($socketRequest.Value.ClientBuffer -gt 0)) + $socketRequest.Value.ClientBuffer.Clear() + if ($MainRunspace.Events.GenerateEvent) { + $MainRunspace.Events.GenerateEvent.Invoke(@( + "$($request.Url.Scheme -replace '^http', 'ws')://", + $httpListener, + @($socketRequest.Value.Context, $socketRequest.Value.WebSocketContet, $socketRequest.Key, $socketRequest.Value), + $jsonMessage + )) + } + $socketRequest.Value.Receiving = + $socketRequest.Value.WebSocket.ReceiveAsync($socketRequest.Value.ClientBuffer, [Threading.CancellationToken]::None) + } + } + } + # If async method fails, + if ($contextAsync.IsFaulted) { + # write an error and continue. + Write-Error -Exception $contextAsync.Exception -Category ProtocolError + continue + } + # Get the context async result. + # The context is basically the next request and response in the queue. + $context = $(try { $contextAsync.Result } catch { $_ }) + + # yield the context immediately, in case anything is watching the output of this job + $context + + $Request, $response = $context.Request, $context.Response + $RequestedUrl = $Request.Url + # Favicons are literally outdated, but they're still requested. + if ($RequestedUrl -match '/favicon.ico$') { + # by returning a 404 for them, we can make the browser stop asking. + $context.Response.StatusCode = 404 + $context.Response.Close() + continue + } + # Now, for the fun part. + # We turn request into a PowerShell events. + # The protocol is the scheme of the request url. + $Protocol = $RequestedUrl.Scheme + # Each event will have the source identifier of the protocol, followed by :// + $eventIdentifier = "$($Protocol)://" + # and by default it will pass a message containing the context. + $messageData = [Ordered]@{Protocol = $protocol; Url = $context.Request.Url;Context = $context} + + if ($Header -and $response) { + foreach ($headerKeyValue in $Header.GetEnumerator()) { + try { + $response.Headers.Add($headerKeyValue.Key, $headerKeyValue.Value) + } catch { + Write-Warning "Cannot add header '$($headerKeyValue.Key)': $_" + } + } + } + + # HttpListeners are quite nice, especially when it comes to websocket upgrades. + # If the request is a websocket request + if ($Request.IsWebSocketRequest) { + # we will change the event identifier to a websocket scheme. + $eventIdentifier = $eventIdentifier -replace '^http', 'ws' + # and call the `AcceptWebSocketAsync` method to upgrade the connection. + $acceptWebSocket = $context.AcceptWebSocketAsync('json') + # Once again, we'll use a tight loop to wait for the upgrade to complete or fail. + while (-not ($acceptWebSocket.IsCompleted -or $acceptWebSocket.IsFaulted -or $acceptWebSocket.IsCanceled)) { } + # and if it fails, + if ($acceptWebSocket.IsFaulted) { + # we will write an error and continue. + Write-Error -Exception $acceptWebSocket.Exception -Category ProtocolError + continue + } + # If it succeeds, capture the result. + $webSocketResult = try { $acceptWebSocket.Result } catch { $_ } + + # If the websocket is open + if ($webSocketResult.WebSocket.State -eq 'open') { + # we have switched protocols! + $Protocol = $requestedUrl.Scheme -replace '^http', 'ws' + + # Now add the result it to the SocketRequests lookup table, using the request trace identifier as the key. + $clientBuffer = $webSocketResult.WebSocket::CreateClientBuffer($BufferSize, $BufferSize) + $socketObject = [PSCustomObject][Ordered]@{ + Context = $context + WebSocketContext = $webSocketResult + WebSocket = $webSocketResult.WebSocket + ClientBuffer = $clientBuffer + Created = [DateTime]::UtcNow + LastMessageTime = $null + Receiving = $webSocketResult.WebSocket.ReceiveAsync($clientBuffer, [Threading.CancellationToken]::None) + MessageQueue = [Collections.Queue]::new() + MessageCount = [long]0 + } + + if (-not $httpListener.SocketRequests["$($webSocketResult.RequestUri)"]) { + $httpListener.SocketRequests["$($webSocketResult.RequestUri)"] = [Collections.Queue]::new() + } + $httpListener.SocketRequests["$($webSocketResult.RequestUri)"].Enqueue($socketObject) + # and add the websocketcontext result to the message data. + $messageData["WebSocketContext"] = $webSocketResult + # also add the websocket result to the message data, + # since many might not exactly know what a "WebSocketContext" is. + $messageData["WebSocket"] = $webSocketResult.WebSocket + } + } + + # Now, we generate the event. + $generateEventArguments = @( + $eventIdentifier, + $httpListener, + @($context) + $messageData + ) + # Get a pointer to the GenerateEvent method (we'll want this later) + if ($MainRunspace.Events.GenerateEvent) { + $MainRunspace.Events.GenerateEvent.Invoke($generateEventArguments) + } + + # Everything below this point is for HTTP requests. + if ($protocol -notmatch '^http') { + continue # so if we're already a websocket, we will skip the rest of this code. + } + + $routedTo = $null + $routeKey = $null + # If we have routes, we will try to find a route that matches the request. + if ($route.Count) { + $routeTable = $route + $potentialRouteKeys = @( + $request.Url.AbsolutePath, + ($request.Url.AbsolutePath -replace '/$'), + "$($request.HttpMethod) $($request.Url.AbsolutePath)", + "$($request.HttpMethod) $($request.Url.AbsolutePath -replace '/$')" + "$($request.HttpMethod) $($request.Url.LocalPath)", + "$($request.HttpMethod) $($request.Url.LocalPath -replace '/$')" + ) + $routedTo = foreach ($potentialKey in $potentialRouteKeys) { + if ($routeTable[$potentialKey]) { + $routeTable[$potentialKey] + $routeKey = $potentialKey + break + } + } + } + + if (-not $routedTo -and $handler) { + # If we have an output handler, try to run it and get the output + $routedTo = if ($handler) { + # We may need to run the handler in a `[PowerShell]` command. + $psCmd = + # This is true if we want `NoLanguage` mode. + if ($runspace.LanguageMode -eq 'NoLanguage' -or + $runspacePool.InitialSessionState.LanguageMode -eq 'NoLanguage') { + # (in which case we'll call .GetPowerShell()) + $handler.GetPowerShell() + } elseif ( + # or if we have a runspace or runspace pool + $Runspace -or $RunspacePool + ) { + # (in which case we'll `.Create()` and `.AddScript()`) + [PowerShell]::Create().AddScript($handler, $true) + } + if ($psCmd) { + # If we have a runspace, we'll use that. + if ($Runspace) { + $psCmd.Runspace = $Runspace + } elseif ($RunspacePool) { + # or, alternatively, we can use a runspace pool. + $psCmd.RunspacePool = $RunspacePool + } + # Now, we can invoke the command. + $psCmd.Invoke(@($context)) + } else { + # Otherwise, we'll just run the handler. + $context | . $handler + } + } + } + + if (-not $routedTo -and $html) { + $routedTo = + # If the content is already html, we will use it as is. + if ($html -match '\" + "" + # and apply the site header. + $SiteHeader -join [Environment]::NewLine + "" + "" + $html + "" + "" + ) -join [Environment]::NewLine + } + } + + # If we routed to a string, we will close the response with the string. + if ($routedTo -is [string]) { + $response.Close($OutputEncoding.GetBytes($routedTo), $true) + continue + } + + # If we've routed to is a byte array, we will close the response with the byte array. + if ($routedTo -is [byte[]]) { + $response.Close($routedTo, $true) + continue + } + + # If we routed to a script block or command, we will try to execute it. + if ($routedTo -is [ScriptBlock] -or + $routedTo -is [Management.Automation.CommandInfo]) { + $routeSplat = [Ordered]@{} + + # If the command had a `-Request` parameter, we will pass the request object. + if ($routeParameters -and $routeParameters[$routeKey].Request) { + $routeSplat['Request'] = $request + } + # If the command had a `-Response` parameter, we will pass the response object. + if ($routeParameters -and $routeParameters[$routeKey].Response) { + $routeSplat['Response'] = $response + } + + # If the request has a query string, we will parse it and pass the values to the command. + if ($request.Url.QueryString) { + $parsedQuery = [Web.HttpUtility]::ParseQueryString($request.Url.QueryString) + foreach ($parsedQueryKey in $parsedQuery.Keys) { + if ($routeParameters[$routeKey][$parsedQueryKey]) { + $routeSplat[$parsedQueryKey] = $parsedQuery[$parsedQueryKey] + } + } + } + # If the request has a content type of json, we will parse the json and pass the values to the command. + if ($request.ContentType -match '^(?>application|text)/json') { + $streamReader = [IO.StreamReader]::new($request.InputStream) + $json = $streamReader.ReadToEnd() + $jsonHashtable = ConvertFrom-Json -InputObject $json -AsHashtable + foreach ($keyValuePair in $jsonHashtable.GetEnumerator()) { + if ($routeParameters[$routeKey][$keyValuePair.Key]) { + $routeSplat[$keyValuePair.Key] = $keyValuePair.Value + } + } + $streamReader.Close() + $streamReader.Dispose() + } + + # If the request has a content type of form-urlencoded, we will parse the form and pass the values to the command. + if ($request.ContentType -eq 'application/x-www-form-urlencoded') { + $streamReader = [IO.StreamReader]::new($request.InputStream) + $formData = [Web.HttpUtility]::ParseQueryString($streamReader.ReadToEnd()) + foreach ($formKey in $formData.Keys) { + if ($routeParameters[$routeKey][$formKey]) { + $routeSplat[$formKey] = $form[$formKey] + } + } + $streamReader.Close() + $streamReader.Dispose() + } + + # We will execute the command and get the output. + $routeOutput = . $routedTo @routeSplat + + # If the output is a string, we will close the response with the string. + if ($routeOutput -is [string]) + { + $response.Close($OutputEncoding.GetBytes($routeOutput), $true) + continue + } + # If the output is a byte array, we will close the response with the byte array. + elseif ($routeOutput -is [byte[]]) + { + $response.Close($routeOutput, $true) + continue + } + # If the response is an array, write the responses out one at a time. + # (note: this will likely be changed in the future) + elseif ($routeOutput -is [object[]]) { + foreach ($routeOut in $routeOutput) { + if ($routeOut -is [string]) { + $routeOut = $OutputEncoding.GetBytes($routeOut) + } + if ($routeOut -is [byte[]]) { + $response.OutputStream.Write($routeOut, 0, $routeOut.Length) + } + } + $response.Close() + } + else { + # If the response was an object, we will convert it to json and close the response with the json. + $responseJson = ConvertTo-Json -InputObject $routeOutput -Depth 3 + $response.ContentType = 'application/json' + $response.Close($OutputEncoding.GetBytes($responseJson), $true) + } + } + } + } } process { + # Sometimes we want to customize the behavior of a command based off of the input object + # So, start off by capturing $_ + $inputObject = $_ + # If the input was a job, we might remap a parameter + if ($inputObject -is 'Management.Automation.Job') { + if ($inputObject.WebSocket -is [Net.WebSockets.ClientWebSocket] -and + $inputObject.SocketUrl) { + $SocketUrl = $inputObject.SocketUrl + } + if ($inputObject.HttpListener -is [Net.HttpListener] -and + $inputObject.RootUrl) { + $RootUrl = $inputObject.RootUrl + } + } + if ((-not $SocketUrl) -and (-not $RootUrl)) { + $socketAndListenerJobs = + foreach ($job in Get-Job) { + if ( + $Job.WebSocket -is [Net.WebSockets.ClientWebSocket] -or + $Job.HttpListener -is [Net.HttpListener] + ) { + $job + } + } + $socketAndListenerJobs + } + # First, let's pack all of the parameters into a dictionary of variables. foreach ($keyValuePair in $PSBoundParameters.GetEnumerator()) { $Variable[$keyValuePair.Key] = $keyValuePair.Value } - $webSocketJob = - if ($WebSocketUri) { + + $Variable['MainRunspace'] = [Runspace]::DefaultRunspace + if (-not $variable['BufferSize']) { + $variable['BufferSize'] = $BufferSize + } + $StartThreadJobSplat = [Ordered]@{ + InitializationScript = $InitializationScript + ThrottleLimit = $ThrottleLimit + } + + # If we're going to be listening for HTTP requests, run a thread job for the server. + if ($RootUrl) { + + if (-not $Name) { + $Name = "$($RootUrl -join '|')" + } + + $existingJob = foreach ($jobWithThisName in (Get-Job -Name $Name -ErrorAction Ignore)) { + if ( + $jobWithThisName.State -in 'Running','NotStarted' -and + $jobWithThisName.HttpListener -is [Net.HttpListener] + ) { + $jobWithThisName + break + } + } + + if ((-not $existingJob) -or $Force) { + $variable['HttpListener'] = $httpListener = [Net.HttpListener]::new() + foreach ($potentialPrefix in $RootUrl) { + if ($potentialPrefix -match '^https?://') { + $httpListener.Prefixes.Add($potentialPrefix) + } else { + $httpListener.Prefixes.Add("http://$potentialPrefix/") + $httpListener.Prefixes.Add("https://$potentialPrefix/") + } + } + $httpListener.Start() + } + + if ($DebugPreference -notin 'SilentlyContinue','Ignore') { + . $SocketServerJob -Variable $Variable + } else { + if ($existingJob -and -not $Force) { + $httpListenerJob = $existingJob + $httpListener = $existingJob.HttpListener + } else { + $httpListenerJob = Start-ThreadJob -ScriptBlock $SocketServerJob -Name "$RootUrl" -ArgumentList $Variable @StartThreadJobSplat + $httpListenerJob.pstypenames.insert(0, 'WebSocket.ThreadJob') + $httpListenerJob.pstypenames.insert(0, 'WebSocket.Server.ThreadJob') + } + } + + # If we have a listener job + if ($httpListenerJob) { + # and the job has not started + if ($httpListenerJob.JobStateInfo.State -eq 'NotStarted') { + # sleep for no time (this will allow the job to start) + Start-Sleep -Milliseconds 0 + } + foreach ($keyValuePair in $Variable.GetEnumerator()) { + $httpListenerJob.psobject.properties.add( + [psnoteproperty]::new($keyValuePair.Key, $keyValuePair.Value), $true + ) + } + + if (-not $Broadcast) { + $httpListenerJob + } + } + } + + # If `-Debug` was passed, + if ($DebugPreference -notin 'SilentlyContinue','Ignore') { + # run the job in the current scope (so we can debug it). + . $SocketClientJob -Variable $Variable + return + } + + # If -Debug was not passed, we're running in a background thread job. + $webSocketJob = + if ($SocketUrl) { + # If we had no name, we will use the SocketUrl as the name. if (-not $name) { - $Name = $WebSocketUri + # and we will ensure that it starts with `ws://` or `wss://` + $Name = $SocketUrl -replace '^http', 'ws' } - $existingJob = foreach ($jobWithThisName in (Get-Job -Name $Name)) { + $existingJob = foreach ($jobWithThisName in (Get-Job -Name $Name -ErrorAction Ignore)) { if ( $jobWithThisName.State -in 'Running','NotStarted' -and $jobWithThisName.WebSocket -is [Net.WebSockets.ClientWebSocket] @@ -335,16 +1289,11 @@ function Get-WebSocket { } } - if ($existingJob) { + if ($existingJob -and -not $Force) { $existingJob } else { - Start-ThreadJob -ScriptBlock $SocketJob -Name $Name -InitializationScript $InitializationScript -ArgumentList $Variable - } - } elseif ($WebSocket) { - if (-not $name) { - $name = "websocket" + Start-ThreadJob -ScriptBlock $SocketClientJob -Name $Name -ArgumentList $Variable @StartThreadJobSplat } - Start-ThreadJob -ScriptBlock $SocketJob -Name $Name -InitializationScript $InitializationScript -ArgumentList $Variable } $subscriptionSplat = @{ @@ -353,33 +1302,90 @@ function Get-WebSocket { SupportEvent = $true } $eventSubscriptions = @( - if ($OnOutput) { - Register-ObjectEvent @subscriptionSplat -InputObject $webSocketJob.Output -Action $OnOutput - } - if ($OnError) { - Register-ObjectEvent @subscriptionSplat -InputObject $webSocketJob.Error -Action $OnError - } - if ($OnWarning) { - Register-ObjectEvent @subscriptionSplat -InputObject $webSocketJob.Warning -Action $OnWarning - } + if ($webSocketJob) { + if ($OnOutput) { + Register-ObjectEvent @subscriptionSplat -InputObject $webSocketJob.Output -Action $OnOutput + } + if ($OnError) { + Register-ObjectEvent @subscriptionSplat -InputObject $webSocketJob.Error -Action $OnError + } + if ($OnWarning) { + Register-ObjectEvent @subscriptionSplat -InputObject $webSocketJob.Warning -Action $OnWarning + } + } ) if ($eventSubscriptions) { $variable['EventSubscriptions'] = $eventSubscriptions } - $webSocketConnectTimeout = [DateTime]::Now + $ConnectionTimeout - while (-not $variable['WebSocket'] -and - ([DateTime]::Now -lt $webSocketConnectTimeout)) { - Start-Sleep -Milliseconds 0 + if ($webSocketJob -and -not $webSocketJob.WebSocket) { + $webSocketConnectTimeout = [DateTime]::Now + $ConnectionTimeout + while (-not $variable['WebSocket'] -and + ([DateTime]::Now -lt $webSocketConnectTimeout)) { + Start-Sleep -Milliseconds 0 + } + + foreach ($keyValuePair in $Variable.GetEnumerator()) { + $webSocketJob.psobject.properties.add( + [psnoteproperty]::new($keyValuePair.Key, $keyValuePair.Value), $true + ) + } + $webSocketJob.pstypenames.insert(0, 'WebSocket.ThreadJob') + $webSocketJob.pstypenames.insert(0, 'WebSocket.Client.ThreadJob') } - - foreach ($keyValuePair in $Variable.GetEnumerator()) { - $webSocketJob.psobject.properties.add( - [psnoteproperty]::new($keyValuePair.Key, $keyValuePair.Value), $true + + # If we're broadcasting a message + if ($Broadcast) { + # find out who is listening. + $socketList = @( + if ($httpListener.SocketRequests) { + @(foreach ($queue in $httpListener.SocketRequests.Values) { + foreach ($socket in $queue) { + if ($socket.WebSocket.State -eq 'Open') { + $socket.WebSocket + } + } + }) + } + if ($webSocketJob.WebSocket) { + $webSocketJob.WebSocket + } ) + + # If no one is listening, write a warning. + if (-not $socketList) { + Write-Warning "No one is listening" + } + + # If the broadcast is a scriptblock or command, run it. + if ($Broadcast -is [ScriptBlock] -or + $Broadcast -is [Management.Automation.CommandInfo]) { + $Broadcast = & $Broadcast + } + # If the broadcast is a byte array, convert it to an array segment. + if ($broadcast -is [byte[]]) { + $broadcast = [ArraySegment[byte]]::new($broadcast) + } + + # If the broadcast is an array segment, send it as binary. + if ($broadcast -is [ArraySegment[byte]]) { + foreach ($socket in $socketList) { + $null = $socket.SendAsync($broadcast, 'Binary', 'EndOfMessage', [Threading.CancellationToken]::None) + } + } + else { + # Otherwise, convert the broadcast to JSON. + $broadcastJson = ConvertTo-Json -InputObject $Broadcast + $broadcastJsonBytes = $OutputEncoding.GetBytes($broadcastJson) + $broadcastSegment = [ArraySegment[byte]]::new($broadcastJsonBytes) + foreach ($socket in $socketList) { + $null = $socket.SendAsync($broadcastSegment, 'Text', 'EndOfMessage', [Threading.CancellationToken]::None) + } + } + $Broadcast # emit the broadcast. } - $webSocketJob.pstypenames.insert(0, 'WebSocketJob') - if ($Watch) { + + if ($Watch -and $webSocketJob) { do { $webSocketJob | Receive-Job Start-Sleep -Milliseconds ( @@ -387,7 +1393,7 @@ function Get-WebSocket { ) } while ($webSocketJob.State -in 'Running','NotStarted') } - elseif ($WatchFor) { + elseif ($WatchFor -and $webSocketJob) { . { do { $webSocketJob | Receive-Job @@ -413,8 +1419,8 @@ function Get-WebSocket { } } } - } - else { + } + elseif ($webSocketJob -and -not $broadcast) { $webSocketJob } } diff --git a/Container.init.ps1 b/Container.init.ps1 index 352fe81..6d0c8cf 100644 --- a/Container.init.ps1 +++ b/Container.init.ps1 @@ -10,19 +10,16 @@ # Thank you Microsoft! Thank you PowerShell! Thank you Docker! FROM mcr.microsoft.com/powershell # Set the shell to PowerShell (thanks again, Docker!) - SHELL ["/bin/pwsh", "-nologo", "-command"] - # Run the initialization script. This will do all remaining initialization in a single layer. - RUN --mount=type=bind,src=./,target=/Initialize ./Initialize/Container.init.ps1 + # Copy the module into the container + RUN --mount=type=bind,src=./,target=/Initialize /bin/pwsh -nologo -command /Initialize/Container.init.ps1 + # Set the entrypoint to the script we just created. + ENTRYPOINT [ "/bin/pwsh","-nologo","-noexit","-file","/Container.start.ps1" ] ~~~ The scripts arguments can be provided with either an `ARG` or `ENV` instruction in the Dockerfile. -.NOTES - Did you know that in PowerShell you can 'use' namespaces that do not really exist? - This seems like a nice way to describe a relationship to a container image. - That is why this file is using the namespace 'mcr.microsoft.com/powershell'. - (this does nothing, but most likely will be used in the future) #> -using namespace 'mcr.microsoft.com/powershell AS powerShell' + +#use container mcr.microsoft.com/powershell param( # The name of the module to be installed. diff --git a/Container.start.ps1 b/Container.start.ps1 index 1f83e5c..bcde545 100644 --- a/Container.start.ps1 +++ b/Container.start.ps1 @@ -24,7 +24,8 @@ That is why this file is using the namespace 'mcr.microsoft.com/powershell'. (this does nothing, but most likely will be used in the future) #> -using namespace 'ghcr.io/powershellweb/websocket' + +#use container ghcr.io/powershellweb/websocket param() @@ -62,7 +63,7 @@ if ($args) { # If a single drive is mounted, start the socket files. $webSocketFiles = $mountedFolders | Get-ChildItem -Filter *.WebSocket.ps1 foreach ($webSocketFile in $webSocketFiles) { - Start-ThreadJob -Name $webSocketFile.Name -ScriptBlock {param($webSocketFile) . $using:webSocketFile.FullName } -ArgumentList $webSocketFile + Start-ThreadJob -Name $webSocketFile.Name -ScriptBlock { . $using:webSocketFile.FullName } . $webSocketFile.FullName } } diff --git a/Container.stop.ps1 b/Container.stop.ps1 index edb66db..bb6dfe6 100644 --- a/Container.stop.ps1 +++ b/Container.stop.ps1 @@ -6,4 +6,4 @@ It can be used to perform any necessary cleanup before the container is stopped. #> -"Container now exiting, thank you for using WebSocket!" | Out-Host +"Container now exiting, thank you for using $env:ModuleName!" | Out-Host diff --git a/Dockerfile b/Dockerfile index 6df5630..be769c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Thank you Microsoft! Thank you PowerShell! Thank you Docker! -FROM mcr.microsoft.com/powershell AS powershell +FROM mcr.microsoft.com/powershell # Set the module name to the name of the module we are building ENV ModuleName=WebSocket diff --git a/README.md b/README.md index 3270710..84f64b3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@
WebSocket Logo (Animated) +
+ + +
# WebSocket @@ -46,15 +50,18 @@ To stop watching a websocket, simply stop the background job. ~~~powershell # Create a WebSocket job that connects to a WebSocket and outputs the results. -Get-WebSocket -WebSocketUri "wss://localhost:9669/" +$socketServer = Get-WebSocket -RootUrl "http://localhost:8387/" -HTML "

WebSocket Server

" +$socketClient = Get-WebSocket -SocketUrl "ws://localhost:8387/" +foreach ($n in 1..10) { $socketServer.Send(@{n=Get-Random}) } +$socketClient | Receive-Job -Keep ~~~ #### Get-WebSocket Example 2 ~~~powershell # Get is the default verb, so we can just say WebSocket. # `-Watch` will output a continous stream of objects from the websocket. -# For example, let's Watch BlueSky, but just the text. -websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Watch | +# For example, let's Watch BlueSky, but just the text +websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Watch -Maximum 1kb | % { $_.commit.record.text } @@ -69,18 +76,28 @@ $blueSkySocketUrl = "wss://jetstream2.us-$( "wantedCollections=app.bsky.feed.post" ) -join '&')" websocket $blueSkySocketUrl -Watch | - % { Write-Host "$(' ' * (Get-Random -Max 10))$($_.commit.record.text)$($(' ' * (Get-Random -Max 10)))"} + % { Write-Host "$(' ' * (Get-Random -Max 10))$($_.commit.record.text)$($(' ' * (Get-Random -Max 10)))"} -Max 1kb ~~~ #### Get-WebSocket Example 4 ~~~powershell +# Watch continuously in a background job. websocket wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post ~~~ #### Get-WebSocket Example 5 +~~~powershell +# Watch the first message in -Debug mode. +# This allows you to literally debug the WebSocket messages as they are encountered. +websocket wss://jetstream2.us-west.bsky.network/subscribe -QueryParameter @{ + wantedCollections = 'app.bsky.feed.post' +} -Max 1 -Debug +~~~ + #### Get-WebSocket Example 6 + ~~~powershell # Watch BlueSky, but just the emoji -websocket jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Tail | +websocket jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Tail -Max 1kb | Foreach-Object { $in = $_ if ($in.commit.record.text -match '[\p{IsHighSurrogates}\p{IsLowSurrogates}]+') { @@ -88,7 +105,7 @@ websocket jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.f } } ~~~ - #### Get-WebSocket Example 6 + #### Get-WebSocket Example 7 ~~~powershell $emojiPattern = '[\p{IsHighSurrogates}\p{IsLowSurrogates}\p{IsVariationSelectors}\p{IsCombiningHalfMarks}]+)' @@ -102,7 +119,7 @@ websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app. } } ~~~ - #### Get-WebSocket Example 7 + #### Get-WebSocket Example 8 ~~~powershell websocket wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Watch | @@ -113,17 +130,19 @@ websocket wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app. $_.commit.record.embed.external.uri } ~~~ - #### Get-WebSocket Example 8 + #### Get-WebSocket Example 9 ~~~powershell # BlueSky, but just the hashtags -websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -WatchFor @{ +websocket wss://jetstream2.us-west.bsky.network/subscribe -QueryParameter @{ + wantedCollections = 'app.bsky.feed.post' +} -WatchFor @{ {$webSocketoutput.commit.record.text -match "\#\w+"}={ $matches.0 } -} +} -Maximum 1kb ~~~ - #### Get-WebSocket Example 9 + #### Get-WebSocket Example 10 ~~~powershell # BlueSky, but just the hashtags (as links) @@ -137,7 +156,7 @@ websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app. } } ~~~ - #### Get-WebSocket Example 10 + #### Get-WebSocket Example 11 ~~~powershell websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -WatchFor @{ @@ -149,7 +168,7 @@ websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app. } } ~~~ - #### Get-WebSocket Example 11 + #### Get-WebSocket Example 12 ~~~powershell # We can decorate a type returned from a WebSocket, allowing us to add additional properties. diff --git a/README.ps.md b/README.ps.md index c3a7d5f..397739e 100644 --- a/README.ps.md +++ b/README.ps.md @@ -1,5 +1,9 @@
WebSocket Logo (Animated) +
+ + +
# WebSocket diff --git a/Types/WebSocket.ThreadJob/Clear.ps1 b/Types/WebSocket.ThreadJob/Clear.ps1 new file mode 100644 index 0000000..ed0c6e2 --- /dev/null +++ b/Types/WebSocket.ThreadJob/Clear.ps1 @@ -0,0 +1 @@ +$this.Output.Clear() \ No newline at end of file diff --git a/Types/WebSocket.ThreadJob/Pop.ps1 b/Types/WebSocket.ThreadJob/Pop.ps1 new file mode 100644 index 0000000..5b64908 --- /dev/null +++ b/Types/WebSocket.ThreadJob/Pop.ps1 @@ -0,0 +1,6 @@ +param() + +if ($this.Output.Count -gt 0) { + $this.Output[0] + $this.Output.RemoveAt(0) +} \ No newline at end of file diff --git a/Types/WebSocket.ThreadJob/Receive.ps1 b/Types/WebSocket.ThreadJob/Receive.ps1 new file mode 100644 index 0000000..36d40e4 --- /dev/null +++ b/Types/WebSocket.ThreadJob/Receive.ps1 @@ -0,0 +1 @@ +$this | Receive-Job -Keep -ErrorAction Ignore \ No newline at end of file diff --git a/Types/WebSocket.ThreadJob/WebSocket.Client.ThreadJob/Send.ps1 b/Types/WebSocket.ThreadJob/WebSocket.Client.ThreadJob/Send.ps1 new file mode 100644 index 0000000..facfe86 --- /dev/null +++ b/Types/WebSocket.ThreadJob/WebSocket.Client.ThreadJob/Send.ps1 @@ -0,0 +1,38 @@ +<# +.SYNOPSIS + Sends a WebSocket message. +.DESCRIPTION + Sends a message to a WebSocket server. +#> +param( +[PSObject] +$Message +) + +function sendMessage { + param([Parameter(ValueFromPipeline)]$msg) + process { + if ($msg -is [byte[]]) { + [ArraySegment[byte]]$messageSegment = [ArraySegment[byte]]::new($msg) + if ($null -ne $messageSegment -and $this.WebSocket.SendAsync) { + $this.WebSocket.SendAsync($messageSegment, 'Binary', 'EndOfMessage',[Threading.Cancellationtoken]::None) + } + } else { + $jsonMessage = ConvertTo-Json -InputObject $msg + $messageSegment = [ArraySegment[byte]]::new($OutputEncoding.GetBytes($jsonMessage)) + if ($null -ne $jsonMessage -and $this.WebSocket.SendAsync) { + $this.WebSocket.SendAsync($messageSegment, 'Text', 'EndOfMessage', [Threading.Cancellationtoken]::None) + } + } + } +} + +if ($message -is [Collections.IList] -and $message -isnot [byte[]]) { + $Message | sendMessage +} else { + sendMessage -msg $Message +} + + + + diff --git a/Types/WebSocket.ThreadJob/WebSocket.Server.ThreadJob/Send.ps1 b/Types/WebSocket.ThreadJob/WebSocket.Server.ThreadJob/Send.ps1 new file mode 100644 index 0000000..9782265 --- /dev/null +++ b/Types/WebSocket.ThreadJob/WebSocket.Server.ThreadJob/Send.ps1 @@ -0,0 +1,69 @@ +<# +.SYNOPSIS + Sends a WebSocket message. +.DESCRIPTION + Sends a message from a WebSocket server. +#> +param( +[PSObject] +$Message, + +[string] +$Pattern +) + +function sendMessage { + param([Parameter(ValueFromPipeline)]$msg, [PSObject[]]$Sockets) + process { + if ($msg -is [byte[]]) { + $messageSegment = [ArraySegment[byte]]::new($msg) + foreach ($socket in $sockets) { + if ($null -ne $messageSegment -and $socket.SendAsync) { + $null = $socket.SendAsync($messageSegment, 'Binary', 'EndOfMessage',[Threading.Cancellationtoken]::None) + } + } + + } else { + $jsonMessage = ConvertTo-Json -InputObject $msg + $messageSegment = [ArraySegment[byte]]::new($OutputEncoding.GetBytes($jsonMessage)) + foreach ($socket in $sockets) { + if ($null -ne $messageSegment -and $socket.SendAsync) { + $null = $socket.SendAsync($messageSegment, 'Binary', 'EndOfMessage',[Threading.Cancellationtoken]::None) + } + } + } + $msg + } +} + +$patternAsRegex = $pattern -as [regex] +$socketList = @( + foreach ($socketConnection in $this.HttpListener.SocketRequests.Values) { + if ( + $patternAsRegex -and + $socketConnection.WebSocketContext.RequestUri -match $pattern + ) { + $socketConnection.WebSocket + } + elseif ( + $pattern -and + $socketConnection.WebSocketContext.RequestUri -like $pattern + ) { + $socketConnection.WebSocket + } + else { + $socketConnection.WebSocket + } + } +) + + +if ($message -is [Collections.IList] -and $message -isnot [byte[]]) { + $Message | sendMessage -Sockets $socketList +} else { + sendMessage -msg $Message -Sockets $socketList +} + + + + diff --git a/Types/WebSocket.ThreadJob/WebSocket.ThreadJob.format.ps1 b/Types/WebSocket.ThreadJob/WebSocket.ThreadJob.format.ps1 new file mode 100644 index 0000000..22e7f8e --- /dev/null +++ b/Types/WebSocket.ThreadJob/WebSocket.ThreadJob.format.ps1 @@ -0,0 +1 @@ +Write-FormatView -TypeName WebSocket.ThreadJob -Property Id, Name, State -AutoSize diff --git a/WebSocket.format.ps1xml b/WebSocket.format.ps1xml new file mode 100644 index 0000000..9c8c52b --- /dev/null +++ b/WebSocket.format.ps1xml @@ -0,0 +1,37 @@ + + + + + WebSocket.ThreadJob + + WebSocket.ThreadJob + + + + + + + + + + + + + + + + Id + + + Name + + + State + + + + + + + + \ No newline at end of file diff --git a/WebSocket.psd1 b/WebSocket.psd1 index 4eb77b6..8b6598c 100644 --- a/WebSocket.psd1 +++ b/WebSocket.psd1 @@ -1,5 +1,5 @@ @{ - ModuleVersion = '0.1.2' + ModuleVersion = '0.1.3' RootModule = 'WebSocket.psm1' Guid = '75c70c8b-e5eb-4a60-982e-a19110a1185d' Author = 'James Brundage' @@ -7,7 +7,9 @@ Copyright = '2024 StartAutomating' Description = 'Work with WebSockets in PowerShell' FunctionsToExport = @('Get-WebSocket') - AliasesToExport = @('WebSocket') + AliasesToExport = @('WebSocket','ws','wss') + FormatsToProcess = @('WebSocket.format.ps1xml') + TypesToProcess = @('WebSocket.types.ps1xml') PrivateData = @{ PSData = @{ Tags = @('WebSocket', 'WebSockets', 'Networking', 'Web') @@ -17,18 +19,44 @@ > Like It? [Star It](https://github.com/PowerShellWeb/WebSocket) > Love It? [Support It](https://github.com/sponsors/StartAutomating) -## WebSocket 0.1.2 +## WebSocket 0.1.3 -* WebSocket now decorates (#34) - * Added a -PSTypeName(s) parameter to Get-WebSocket, so we can extend the output. -* Reusing WebSockets (#35) - * If a WebSocketUri is already open, we will reuse it. -* Explicitly exporting commands (#38) - * This should enable automatic import and enable Find-Command +WebSocket server support! + +### Server Features + +For consistency, capabilities, and aesthetics, +this is a fairly fully features HTTP server that happens to support websockets + +* `Get-WebSocket` `-RootURL/-HostHeader` ( #47 ) +* `-StyleSheet` lets you include stylesheets ( #64 ) +* `-JavaScript` lets you include javascript ( #65 ) +* `-Timeout/-LifeSpan` server support ( #85 ) + +### Client Improvements + +* `Get-WebSocket -QueryParameter` lets you specify query parameters ( #41 ) +* `Get-WebSocket -Debug` lets you debug the websocketjob. ( #45 ) +* `Get-WebSocket -SubProtocol` lets you specify a subprotocol (defaults to JSON) ( #46 ) +* `Get-WebSocket -Authenticate` allows sends pre-connection authentication ( #69 ) +* `Get-WebSocket -Handshake` allows post-connection authentication ( #81 ) +* `Get-WebSocket -Force` allows the creation of multiple clients ( #58 ) + + +### General Improvements + +* `Get-WebSocket -SupportsPaging` ( #55 ) +* `Get-WebSocket -BufferSize 64kb` ( #52 ) +* `Get-WebSocket -Force` ( #58 ) +* `Get-WebSocket -Filter` ( #42 ) +* `Get-WebSocket -ForwardEvent` ( #56 ) +* `Get-WebSocket` Parameter Sets ( #73, #74 ) +* `-Broadcast` lets you broadcast to clients and servers ( #39 ) +* `Get-WebSocket` quieting previous job check ( #43 ) --- -Additional details available in the [CHANGELOG](CHANGELOG.md) +Additional details available in the [CHANGELOG](https://github.com/PowerShellWeb/WebSocket/blob/main/CHANGELOG.md) '@ } } diff --git a/WebSocket.types.ps1xml b/WebSocket.types.ps1xml new file mode 100644 index 0000000..7d1f114 --- /dev/null +++ b/WebSocket.types.ps1xml @@ -0,0 +1,206 @@ + + + + WebSocket.Client.ThreadJob + + + Clear + + + + Pop + + + + Receive + + + + Send + + + + + + WebSocket.Server.ThreadJob + + + Clear + + + + Pop + + + + Receive + + + + Send + + + + + + WebSocket.ThreadJob + + + Clear + + + + Pop + + + + Receive + + + + + \ No newline at end of file diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a2ac339..3314467 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,41 @@ > Like It? [Star It](https://github.com/PowerShellWeb/WebSocket) > Love It? [Support It](https://github.com/sponsors/StartAutomating) +## WebSocket 0.1.3 + +WebSocket server support! + +### Server Features + +For consistency, capabilities, and aesthetics, +this is a fairly fully features HTTP server that happens to support websockets + +* `Get-WebSocket` `-RootURL/-HostHeader` ( #47 ) +* `-StyleSheet` lets you include stylesheets ( #64 ) +* `-JavaScript` lets you include javascript ( #65 ) +* `-Timeout/-LifeSpan` server support ( #85 ) + +### Client Improvements + +* `Get-WebSocket -QueryParameter` lets you specify query parameters ( #41 ) +* `Get-WebSocket -Debug` lets you debug the websocketjob. ( #45 ) +* `Get-WebSocket -SubProtocol` lets you specify a subprotocol (defaults to JSON) ( #46 ) +* `Get-WebSocket -Authenticate` allows sends pre-connection authentication ( #69 ) +* `Get-WebSocket -Handshake` allows post-connection authentication ( #81 ) +* `Get-WebSocket -Force` allows the creation of multiple clients ( #58 ) + + +### General Improvements + +* `Get-WebSocket -SupportsPaging` ( #55 ) +* `Get-WebSocket -BufferSize 64kb` ( #52 ) +* `Get-WebSocket -Force` ( #58 ) +* `Get-WebSocket -Filter` ( #42 ) +* `Get-WebSocket -ForwardEvent` ( #56 ) +* `Get-WebSocket` Parameter Sets ( #73, #74 ) +* `-Broadcast` lets you broadcast to clients and servers ( #39 ) +* `Get-WebSocket` quieting previous job check ( #43 ) + ## WebSocket 0.1.2 * WebSocket now decorates (#34) diff --git a/docs/Get-WebSocket.md b/docs/Get-WebSocket.md index f252bef..0e76005 100644 --- a/docs/Get-WebSocket.md +++ b/docs/Get-WebSocket.md @@ -16,18 +16,30 @@ If the `-Watch` parameter is provided, will output a continous stream of objects --- +### Related Links +* [https://websocket.powershellweb.com/Get-WebSocket/](https://websocket.powershellweb.com/Get-WebSocket/) + +* [https://learn.microsoft.com/en-us/dotnet/api/system.net.websockets.clientwebsocket?wt.mc_id=MVP_321542](https://learn.microsoft.com/en-us/dotnet/api/system.net.websockets.clientwebsocket?wt.mc_id=MVP_321542) + +* [https://learn.microsoft.com/en-us/dotnet/api/system.net.httplistener?wt.mc_id=MVP_321542](https://learn.microsoft.com/en-us/dotnet/api/system.net.httplistener?wt.mc_id=MVP_321542) + +--- + ### Examples Create a WebSocket job that connects to a WebSocket and outputs the results. ```PowerShell -Get-WebSocket -WebSocketUri "wss://localhost:9669/" +$socketServer = Get-WebSocket -RootUrl "http://localhost:8387/" -HTML "

WebSocket Server

" +$socketClient = Get-WebSocket -SocketUrl "ws://localhost:8387/" +foreach ($n in 1..10) { $socketServer.Send(@{n=Get-Random}) } +$socketClient | Receive-Job -Keep ``` Get is the default verb, so we can just say WebSocket. `-Watch` will output a continous stream of objects from the websocket. -For example, let's Watch BlueSky, but just the text. +For example, let's Watch BlueSky, but just the text ```PowerShell -websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Watch | +websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Watch -Maximum 1kb | % { $_.commit.record.text } @@ -41,17 +53,25 @@ $blueSkySocketUrl = "wss://jetstream2.us-$( "wantedCollections=app.bsky.feed.post" ) -join '&')" websocket $blueSkySocketUrl -Watch | - % { Write-Host "$(' ' * (Get-Random -Max 10))$($_.commit.record.text)$($(' ' * (Get-Random -Max 10)))"} + % { Write-Host "$(' ' * (Get-Random -Max 10))$($_.commit.record.text)$($(' ' * (Get-Random -Max 10)))"} -Max 1kb ``` -> EXAMPLE 4 +Watch continuously in a background job. ```PowerShell websocket wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post ``` +Watch the first message in -Debug mode. +This allows you to literally debug the WebSocket messages as they are encountered. + +```PowerShell +websocket wss://jetstream2.us-west.bsky.network/subscribe -QueryParameter @{ + wantedCollections = 'app.bsky.feed.post' +} -Max 1 -Debug +``` Watch BlueSky, but just the emoji ```PowerShell -websocket jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Tail | +websocket jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Tail -Max 1kb | Foreach-Object { $in = $_ if ($in.commit.record.text -match '[\p{IsHighSurrogates}\p{IsLowSurrogates}]+') { @@ -59,7 +79,7 @@ websocket jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.f } } ``` -> EXAMPLE 6 +> EXAMPLE 7 ```PowerShell $emojiPattern = '[\p{IsHighSurrogates}\p{IsLowSurrogates}\p{IsVariationSelectors}\p{IsCombiningHalfMarks}]+)' @@ -73,7 +93,7 @@ websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app. } } ``` -> EXAMPLE 7 +> EXAMPLE 8 ```PowerShell websocket wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Watch | @@ -87,11 +107,13 @@ websocket wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app. BlueSky, but just the hashtags ```PowerShell -websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -WatchFor @{ +websocket wss://jetstream2.us-west.bsky.network/subscribe -QueryParameter @{ + wantedCollections = 'app.bsky.feed.post' +} -WatchFor @{ {$webSocketoutput.commit.record.text -match "\#\w+"}={ $matches.0 } -} +} -Maximum 1kb ``` BlueSky, but just the hashtags (as links) @@ -106,7 +128,7 @@ websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app. } } ``` -> EXAMPLE 10 +> EXAMPLE 11 ```PowerShell websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -WatchFor @{ @@ -141,20 +163,98 @@ $somePosts | --- ### Parameters -#### **WebSocketUri** -The Uri of the WebSocket to connect to. +#### **SocketUrl** +The WebSocket Uri. + +|Type |Required|Position|PipelineInput |Aliases | +|-------|--------|--------|---------------------|---------------------------------------------| +|`[Uri]`|false |1 |true (ByPropertyName)|Url
Uri
WebSocketUri
WebSocketUrl| + +#### **RootUrl** +One or more root urls. +If these are provided, a WebSocket server will be created with these listener prefixes. + +|Type |Required|Position|PipelineInput |Aliases | +|------------|--------|--------|---------------------|-------------------------------------------------------------------------------------| +|`[String[]]`|true |named |true (ByPropertyName)|HostHeader
Host
CNAME
ListenerPrefix
ListenerPrefixes
ListenerUrl| + +#### **Route** +A route table for all requests. + +|Type |Required|Position|PipelineInput |Aliases | +|---------------|--------|--------|---------------------|----------------------------------------------| +|`[IDictionary]`|false |named |true (ByPropertyName)|Routes
RouteTable
WebHook
WebHooks| -|Type |Required|Position|PipelineInput |Aliases | -|-------|--------|--------|---------------------|-----------| -|`[Uri]`|false |1 |true (ByPropertyName)|Url
Uri| +#### **HTML** +The Default HTML. +This will be displayed when visiting the root url. + +|Type |Required|Position|PipelineInput |Aliases | +|----------|--------|--------|---------------------|------------------------------------------------------------| +|`[String]`|false |named |true (ByPropertyName)|DefaultHTML
Home
Index
IndexHTML
DefaultPage| + +#### **PaletteName** +The name of the palette to use. This will include the [4bitcss](https://4bitcss.com) stylesheet. + +|Type |Required|Position|PipelineInput |Aliases | +|----------|--------|--------|---------------------|----------------------------------------| +|`[String]`|false |named |true (ByPropertyName)|Palette
ColorScheme
ColorPalette| + +#### **GoogleFont** +The [Google Font](https://fonts.google.com/) name. + +|Type |Required|Position|PipelineInput |Aliases | +|----------|--------|--------|---------------------|--------| +|`[String]`|false |named |true (ByPropertyName)|FontName| + +#### **CodeFont** +The Google Font name to use for code blocks. +(this should be a [monospace font](https://fonts.google.com/?classification=Monospace)) + +|Type |Required|Position|PipelineInput |Aliases | +|----------|--------|--------|---------------------|----------------------------------------| +|`[String]`|false |named |true (ByPropertyName)|PreFont
CodeFontName
PreFontName| + +#### **JavaScript** +A list of javascript files or urls to include in the content. + +|Type |Required|Position|PipelineInput | +|------------|--------|--------|---------------------| +|`[String[]]`|false |named |true (ByPropertyName)| + +#### **ImportMap** +A javascript import map. This allows you to import javascript modules. + +|Type |Required|Position|PipelineInput |Aliases | +|---------------|--------|--------|---------------------|---------------------------------------------------------------| +|`[IDictionary]`|false |named |true (ByPropertyName)|ImportsJavaScript
JavaScriptImports
JavaScriptImportMap| + +#### **QueryParameter** +A collection of query parameters. +These will be appended onto the `-SocketUrl`. +Multiple values for a single parameter will be passed as multiple parameters. + +|Type |Required|Position|PipelineInput |Aliases | +|---------------|--------|--------|---------------------|-------------------------| +|`[IDictionary]`|false |named |true (ByPropertyName)|QueryParameters
Query| #### **Handler** -A ScriptBlock that will handle the output of the WebSocket. +A ScriptBlock that can handle the output of the WebSocket or the Http Request. +This may be run in a separate `-Runspace` or `-RunspacePool`. +The output of the WebSocket or the Context will be passed as an object. |Type |Required|Position|PipelineInput| |---------------|--------|--------|-------------| |`[ScriptBlock]`|false |named |false | +#### **ForwardEvent** +If set, will forward websocket messages as events. +Only events that match -Filter will be forwarded. + +|Type |Required|Position|PipelineInput |Aliases| +|----------|--------|--------|---------------------|-------| +|`[Switch]`|false |named |true (ByPropertyName)|Forward| + #### **Variable** Any variables to declare in the WebSocket job. These variables will also be added to the job as properties. @@ -163,6 +263,13 @@ These variables will also be added to the job as properties. |---------------|--------|--------|-------------| |`[IDictionary]`|false |named |false | +#### **Header** +Any Http Headers to include in the WebSocket request or server response. + +|Type |Required|Position|PipelineInput|Aliases| +|---------------|--------|--------|-------------|-------| +|`[IDictionary]`|false |named |false |Headers| + #### **Name** The name of the WebSocket job. @@ -180,9 +287,17 @@ The script to run when the WebSocket job starts. #### **BufferSize** The buffer size. Defaults to 16kb. -|Type |Required|Position|PipelineInput| -|---------|--------|--------|-------------| -|`[Int32]`|false |named |false | +|Type |Required|Position|PipelineInput | +|---------|--------|--------|---------------------| +|`[Int32]`|false |named |true (ByPropertyName)| + +#### **Broadcast** +If provided, will send an object. +If this is a scriptblock, it will be run and the output will be sent. + +|Type |Required|Position|PipelineInput|Aliases| +|------------|--------|--------|-------------|-------| +|`[PSObject]`|false |named |false |Send | #### **OnConnect** The ScriptBlock to run after connection to a websocket. @@ -213,6 +328,28 @@ The Scriptblock to run when the WebSocket job produces a warning. |---------------|--------|--------|-------------| |`[ScriptBlock]`|false |named |false | +#### **Authenticate** +If provided, will authenticate the WebSocket. +Many websockets require an initial authentication handshake +after an initial message is received. +This parameter can be either a ScriptBlock or any other object. +If it is a ScriptBlock, it will be run with the output of the WebSocket passed as the first argument. +This will run after the socket is connected but before any messages are received. + +|Type |Required|Position|PipelineInput|Aliases | +|------------|--------|--------|-------------|--------------------------| +|`[PSObject]`|false |named |false |Authorize
HelloMessage| + +#### **Handshake** +If provided, will shake hands after the first websocket message is received. +This parameter can be either a ScriptBlock or any other object. +If it is a ScriptBlock, it will be run with the output of the WebSocket passed as the first argument. +This will run after the socket is connected and the first message is received. + +|Type |Required|Position|PipelineInput|Aliases | +|------------|--------|--------|-------------|--------| +|`[PSObject]`|false |named |false |Identify| + #### **Watch** If set, will watch the output of the WebSocket job, outputting results continuously instead of outputting a websocket job. @@ -234,64 +371,125 @@ If set, will output the raw bytes that come out of the WebSocket. |----------|--------|--------|-------------|---------------------------------------| |`[Switch]`|false |named |false |RawByte
RawBytes
Bytes
Byte| +#### **Force** +If set, will force a new job to be created, rather than reusing an existing job. + +|Type |Required|Position|PipelineInput| +|----------|--------|--------|-------------| +|`[Switch]`|false |named |false | + +#### **SubProtocol** +The subprotocol used by the websocket. If not provided, this will default to `json`. + +|Type |Required|Position|PipelineInput | +|----------|--------|--------|---------------------| +|`[String]`|false |named |true (ByPropertyName)| + +#### **NoSubProtocol** +If set, will not set a subprotocol. This will only work with certain websocket servers, but will not work with an HTTP Listener WebSocket. + +|Type |Required|Position|PipelineInput | +|----------|--------|--------|---------------------| +|`[Switch]`|false |named |true (ByPropertyName)| + +#### **Filter** +One or more filters to apply to the output of the WebSocket. +These can be strings, regexes, scriptblocks, or commands. +If they are strings or regexes, they will be applied to the raw text. +If they are scriptblocks, they will be applied to the deserialized JSON. +These filters will be run within the WebSocket job. + +|Type |Required|Position|PipelineInput | +|--------------|--------|--------|---------------------| +|`[PSObject[]]`|false |named |true (ByPropertyName)| + #### **WatchFor** If set, will watch the output of a WebSocket job for one or more conditions. The conditions are the keys of the dictionary, and can be a regex, a string, or a scriptblock. The values of the dictionary are what will happen when a match is found. -|Type |Required|Position|PipelineInput|Aliases | -|---------------|--------|--------|-------------|----------------------| -|`[IDictionary]`|false |named |false |WhereFor
Wherefore| +|Type |Required|Position|PipelineInput |Aliases | +|---------------|--------|--------|---------------------|----------------------| +|`[IDictionary]`|false |named |true (ByPropertyName)|WhereFor
Wherefore| #### **TimeOut** -The timeout for the WebSocket connection. If this is provided, after the timeout elapsed, the WebSocket will be closed. +The timeout for the WebSocket connection. +If this is provided, after the timeout elapsed, the WebSocket will be closed. -|Type |Required|Position|PipelineInput| -|------------|--------|--------|-------------| -|`[TimeSpan]`|false |named |false | +|Type |Required|Position|PipelineInput |Aliases | +|------------|--------|--------|---------------------|--------| +|`[TimeSpan]`|false |named |true (ByPropertyName)|Lifespan| #### **PSTypeName** If provided, will decorate the objects outputted from a websocket job. This will only decorate objects converted from JSON. -|Type |Required|Position|PipelineInput|Aliases | -|------------|--------|--------|-------------|---------------------------------------| -|`[String[]]`|false |named |false |PSTypeNames
Decorate
Decoration| +|Type |Required|Position|PipelineInput |Aliases | +|------------|--------|--------|---------------------|---------------------------------------| +|`[String[]]`|false |named |true (ByPropertyName)|PSTypeNames
Decorate
Decoration| #### **Maximum** The maximum number of messages to receive before closing the WebSocket. -|Type |Required|Position|PipelineInput| -|---------|--------|--------|-------------| -|`[Int64]`|false |named |false | +|Type |Required|Position|PipelineInput | +|---------|--------|--------|---------------------| +|`[Int64]`|false |named |true (ByPropertyName)| + +#### **ThrottleLimit** +The throttle limit used when creating background jobs. + +|Type |Required|Position|PipelineInput | +|---------|--------|--------|---------------------| +|`[Int32]`|false |named |true (ByPropertyName)| #### **ConnectionTimeout** The maximum time to wait for a connection to be established. By default, this is 7 seconds. -|Type |Required|Position|PipelineInput| -|------------|--------|--------|-------------| -|`[TimeSpan]`|false |named |false | +|Type |Required|Position|PipelineInput | +|------------|--------|--------|---------------------| +|`[TimeSpan]`|false |named |true (ByPropertyName)| #### **Runspace** The Runspace where the handler should run. Runspaces allow you to limit the scope of the handler. -|Type |Required|Position|PipelineInput| -|------------|--------|--------|-------------| -|`[Runspace]`|false |named |false | +|Type |Required|Position|PipelineInput | +|------------|--------|--------|---------------------| +|`[Runspace]`|false |named |true (ByPropertyName)| #### **RunspacePool** The RunspacePool where the handler should run. RunspacePools allow you to limit the scope of the handler to a pool of runspaces. -|Type |Required|Position|PipelineInput|Aliases| -|----------------|--------|--------|-------------|-------| -|`[RunspacePool]`|false |named |false |Pool | +|Type |Required|Position|PipelineInput |Aliases| +|----------------|--------|--------|---------------------|-------| +|`[RunspacePool]`|false |named |true (ByPropertyName)|Pool | + +#### **IncludeTotalCount** + +|Type |Required|Position|PipelineInput| +|----------|--------|--------|-------------| +|`[Switch]`|false |named |false | + +#### **Skip** + +|Type |Required|Position|PipelineInput| +|----------|--------|--------|-------------| +|`[UInt64]`|false |named |false | + +#### **First** + +|Type |Required|Position|PipelineInput| +|----------|--------|--------|-------------| +|`[UInt64]`|false |named |false | --- ### Syntax ```PowerShell -Get-WebSocket [[-WebSocketUri] ] [-Handler ] [-Variable ] [-Name ] [-InitializationScript ] [-BufferSize ] [-OnConnect ] [-OnError ] [-OnOutput ] [-OnWarning ] [-Watch] [-RawText] [-Binary] [-WatchFor ] [-TimeOut ] [-PSTypeName ] [-Maximum ] [-ConnectionTimeout ] [-Runspace ] [-RunspacePool ] [] +Get-WebSocket [[-SocketUrl] ] [-QueryParameter ] [-Handler ] [-ForwardEvent] [-Variable ] [-Header ] [-Name ] [-InitializationScript ] [-BufferSize ] [-Broadcast ] [-OnConnect ] [-OnError ] [-OnOutput ] [-OnWarning ] [-Authenticate ] [-Handshake ] [-Watch] [-RawText] [-Binary] [-Force] [-SubProtocol ] [-NoSubProtocol] [-Filter ] [-WatchFor ] [-TimeOut ] [-PSTypeName ] [-Maximum ] [-ThrottleLimit ] [-ConnectionTimeout ] [-Runspace ] [-RunspacePool ] [-IncludeTotalCount] [-Skip ] [-First ] [] +``` +```PowerShell +Get-WebSocket -RootUrl [-Route ] [-HTML ] [-PaletteName ] [-GoogleFont ] [-CodeFont ] [-JavaScript ] [-ImportMap ] [-Handler ] [-Variable ] [-Header ] [-Name ] [-InitializationScript ] [-BufferSize ] [-Broadcast ] [-Force] [-TimeOut ] [-Maximum ] [-ThrottleLimit ] [-Runspace ] [-RunspacePool ] [-IncludeTotalCount] [-Skip ] [-First ] [] ``` diff --git a/docs/README.md b/docs/README.md index dce8d2f..a4c7c52 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,9 @@
WebSocket Logo (Animated) +
+ + +
# WebSocket @@ -46,15 +50,18 @@ To stop watching a websocket, simply stop the background job. ~~~powershell # Create a WebSocket job that connects to a WebSocket and outputs the results. -Get-WebSocket -WebSocketUri "wss://localhost:9669/" +$socketServer = Get-WebSocket -RootUrl "http://localhost:8387/" -HTML "

WebSocket Server

" +$socketClient = Get-WebSocket -SocketUrl "ws://localhost:8387/" +foreach ($n in 1..10) { $socketServer.Send(@{n=Get-Random}) } +$socketClient | Receive-Job -Keep ~~~ #### Get-WebSocket Example 2 ~~~powershell # Get is the default verb, so we can just say WebSocket. # `-Watch` will output a continous stream of objects from the websocket. -# For example, let's Watch BlueSky, but just the text. -websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Watch | +# For example, let's Watch BlueSky, but just the text +websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Watch -Maximum 1kb | % { $_.commit.record.text } @@ -69,18 +76,28 @@ $blueSkySocketUrl = "wss://jetstream2.us-$( "wantedCollections=app.bsky.feed.post" ) -join '&')" websocket $blueSkySocketUrl -Watch | - % { Write-Host "$(' ' * (Get-Random -Max 10))$($_.commit.record.text)$($(' ' * (Get-Random -Max 10)))"} + % { Write-Host "$(' ' * (Get-Random -Max 10))$($_.commit.record.text)$($(' ' * (Get-Random -Max 10)))"} -Max 1kb ~~~ #### Get-WebSocket Example 4 ~~~powershell +# Watch continuously in a background job. websocket wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post ~~~ #### Get-WebSocket Example 5 +~~~powershell +# Watch the first message in -Debug mode. +# This allows you to literally debug the WebSocket messages as they are encountered. +websocket wss://jetstream2.us-west.bsky.network/subscribe -QueryParameter @{ + wantedCollections = 'app.bsky.feed.post' +} -Max 1 -Debug +~~~ + #### Get-WebSocket Example 6 + ~~~powershell # Watch BlueSky, but just the emoji -websocket jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Tail | +websocket jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Tail -Max 1kb | Foreach-Object { $in = $_ if ($in.commit.record.text -match '[\p{IsHighSurrogates}\p{IsLowSurrogates}]+') { @@ -88,7 +105,7 @@ websocket jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.f } } ~~~ - #### Get-WebSocket Example 6 + #### Get-WebSocket Example 7 ~~~powershell $emojiPattern = '[\p{IsHighSurrogates}\p{IsLowSurrogates}\p{IsVariationSelectors}\p{IsCombiningHalfMarks}]+)' @@ -102,7 +119,7 @@ websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app. } } ~~~ - #### Get-WebSocket Example 7 + #### Get-WebSocket Example 8 ~~~powershell websocket wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Watch | @@ -113,17 +130,19 @@ websocket wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app. $_.commit.record.embed.external.uri } ~~~ - #### Get-WebSocket Example 8 + #### Get-WebSocket Example 9 ~~~powershell # BlueSky, but just the hashtags -websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -WatchFor @{ +websocket wss://jetstream2.us-west.bsky.network/subscribe -QueryParameter @{ + wantedCollections = 'app.bsky.feed.post' +} -WatchFor @{ {$webSocketoutput.commit.record.text -match "\#\w+"}={ $matches.0 } -} +} -Maximum 1kb ~~~ - #### Get-WebSocket Example 9 + #### Get-WebSocket Example 10 ~~~powershell # BlueSky, but just the hashtags (as links) @@ -137,7 +156,7 @@ websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app. } } ~~~ - #### Get-WebSocket Example 10 + #### Get-WebSocket Example 11 ~~~powershell websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -WatchFor @{ @@ -149,7 +168,7 @@ websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app. } } ~~~ - #### Get-WebSocket Example 11 + #### Get-WebSocket Example 12 ~~~powershell # We can decorate a type returned from a WebSocket, allowing us to add additional properties. diff --git a/docs/WebSocket/Client/ThreadJob/README.md b/docs/WebSocket/Client/ThreadJob/README.md new file mode 100644 index 0000000..a15b69a --- /dev/null +++ b/docs/WebSocket/Client/ThreadJob/README.md @@ -0,0 +1,7 @@ +## WebSocket.Client.ThreadJob + + +### Script Methods + + +* [Send()](Send.md) diff --git a/docs/WebSocket/Client/ThreadJob/Send.md b/docs/WebSocket/Client/ThreadJob/Send.md new file mode 100644 index 0000000..c18eca5 --- /dev/null +++ b/docs/WebSocket/Client/ThreadJob/Send.md @@ -0,0 +1,22 @@ +WebSocket.Client.ThreadJob.Send() +--------------------------------- + +### Synopsis +Sends a WebSocket message. + +--- + +### Description + +Sends a message to a WebSocket server. + +--- + +### Parameters +#### **Message** + +|Type |Required|Position|PipelineInput| +|------------|--------|--------|-------------| +|`[PSObject]`|false |1 |false | + +--- diff --git a/docs/WebSocket/Server/ThreadJob/README.md b/docs/WebSocket/Server/ThreadJob/README.md new file mode 100644 index 0000000..b98bd30 --- /dev/null +++ b/docs/WebSocket/Server/ThreadJob/README.md @@ -0,0 +1,7 @@ +## WebSocket.Server.ThreadJob + + +### Script Methods + + +* [Send()](Send.md) diff --git a/docs/WebSocket/Server/ThreadJob/Send.md b/docs/WebSocket/Server/ThreadJob/Send.md new file mode 100644 index 0000000..b7272b0 --- /dev/null +++ b/docs/WebSocket/Server/ThreadJob/Send.md @@ -0,0 +1,28 @@ +WebSocket.Server.ThreadJob.Send() +--------------------------------- + +### Synopsis +Sends a WebSocket message. + +--- + +### Description + +Sends a message from a WebSocket server. + +--- + +### Parameters +#### **Message** + +|Type |Required|Position|PipelineInput| +|------------|--------|--------|-------------| +|`[PSObject]`|false |1 |false | + +#### **Pattern** + +|Type |Required|Position|PipelineInput| +|----------|--------|--------|-------------| +|`[String]`|false |2 |false | + +--- diff --git a/docs/_data/Help/Get-WebSocket.json b/docs/_data/Help/Get-WebSocket.json index 8d58a08..0d88615 100644 --- a/docs/_data/Help/Get-WebSocket.json +++ b/docs/_data/Help/Get-WebSocket.json @@ -28,60 +28,69 @@ "Outputs": [ null ], - "Links": [], + "Links": [ + "https://websocket.powershellweb.com/Get-WebSocket/", + "https://learn.microsoft.com/en-us/dotnet/api/system.net.websockets.clientwebsocket?wt.mc_id=MVP_321542", + "https://learn.microsoft.com/en-us/dotnet/api/system.net.httplistener?wt.mc_id=MVP_321542" + ], "Examples": [ { "Title": "EXAMPLE 1", "Markdown": "Create a WebSocket job that connects to a WebSocket and outputs the results.", - "Code": "Get-WebSocket -WebSocketUri \"wss://localhost:9669/\"" + "Code": "$socketServer = Get-WebSocket -RootUrl \"http://localhost:8387/\" -HTML \"

WebSocket Server

\"\n$socketClient = Get-WebSocket -SocketUrl \"ws://localhost:8387/\"\nforeach ($n in 1..10) { $socketServer.Send(@{n=Get-Random}) }\n$socketClient | Receive-Job -Keep" }, { "Title": "EXAMPLE 2", - "Markdown": "Get is the default verb, so we can just say WebSocket.\n`-Watch` will output a continous stream of objects from the websocket.\nFor example, let's Watch BlueSky, but just the text. ", - "Code": "websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Watch |\n % { \n $_.commit.record.text\n }" + "Markdown": "Get is the default verb, so we can just say WebSocket.\n`-Watch` will output a continous stream of objects from the websocket.\nFor example, let's Watch BlueSky, but just the text ", + "Code": "websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Watch -Maximum 1kb |\n % { \n $_.commit.record.text\n }" }, { "Title": "EXAMPLE 3", "Markdown": "Watch BlueSky, but just the text and spacing", - "Code": "$blueSkySocketUrl = \"wss://jetstream2.us-$(\n 'east','west'|Get-Random\n).bsky.network/subscribe?$(@(\n \"wantedCollections=app.bsky.feed.post\"\n) -join '&')\"\nwebsocket $blueSkySocketUrl -Watch | \n % { Write-Host \"$(' ' * (Get-Random -Max 10))$($_.commit.record.text)$($(' ' * (Get-Random -Max 10)))\"}" + "Code": "$blueSkySocketUrl = \"wss://jetstream2.us-$(\n 'east','west'|Get-Random\n).bsky.network/subscribe?$(@(\n \"wantedCollections=app.bsky.feed.post\"\n) -join '&')\"\nwebsocket $blueSkySocketUrl -Watch | \n % { Write-Host \"$(' ' * (Get-Random -Max 10))$($_.commit.record.text)$($(' ' * (Get-Random -Max 10)))\"} -Max 1kb" }, { "Title": "EXAMPLE 4", - "Markdown": "", + "Markdown": "Watch continuously in a background job.", "Code": "websocket wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post" }, { "Title": "EXAMPLE 5", - "Markdown": "Watch BlueSky, but just the emoji", - "Code": "websocket jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Tail |\n Foreach-Object {\n $in = $_\n if ($in.commit.record.text -match '[\\p{IsHighSurrogates}\\p{IsLowSurrogates}]+') {\n Write-Host $matches.0 -NoNewline\n }\n }" + "Markdown": "Watch the first message in -Debug mode. \nThis allows you to literally debug the WebSocket messages as they are encountered.", + "Code": "websocket wss://jetstream2.us-west.bsky.network/subscribe -QueryParameter @{\n wantedCollections = 'app.bsky.feed.post'\n} -Max 1 -Debug" }, { "Title": "EXAMPLE 6", + "Markdown": "Watch BlueSky, but just the emoji", + "Code": "websocket jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Tail -Max 1kb |\n Foreach-Object {\n $in = $_\n if ($in.commit.record.text -match '[\\p{IsHighSurrogates}\\p{IsLowSurrogates}]+') {\n Write-Host $matches.0 -NoNewline\n }\n }" + }, + { + "Title": "EXAMPLE 7", "Markdown": "", "Code": "$emojiPattern = '[\\p{IsHighSurrogates}\\p{IsLowSurrogates}\\p{IsVariationSelectors}\\p{IsCombiningHalfMarks}]+)'\nwebsocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Tail |\n Foreach-Object {\n $in = $_\n $spacing = (' ' * (Get-Random -Minimum 0 -Maximum 7))\n if ($in.commit.record.text -match \"(?>(?:$emojiPattern|\\#\\w+)\") {\n $match = $matches.0 \n Write-Host $spacing,$match,$spacing -NoNewline\n }\n }" }, { - "Title": "EXAMPLE 7", + "Title": "EXAMPLE 8", "Markdown": "", "Code": "websocket wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -Watch |\n Where-Object {\n $_.commit.record.embed.'$type' -eq 'app.bsky.embed.external'\n } |\n Foreach-Object {\n $_.commit.record.embed.external.uri\n }" }, { - "Title": "EXAMPLE 8", + "Title": "EXAMPLE 9", "Markdown": "BlueSky, but just the hashtags", - "Code": "websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -WatchFor @{\n {$webSocketoutput.commit.record.text -match \"\\#\\w+\"}={\n $matches.0\n } \n}" + "Code": "websocket wss://jetstream2.us-west.bsky.network/subscribe -QueryParameter @{\n wantedCollections = 'app.bsky.feed.post'\n} -WatchFor @{\n {$webSocketoutput.commit.record.text -match \"\\#\\w+\"}={\n $matches.0\n } \n} -Maximum 1kb" }, { - "Title": "EXAMPLE 9", + "Title": "EXAMPLE 10", "Markdown": "BlueSky, but just the hashtags (as links)", "Code": "websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -WatchFor @{\n {$webSocketoutput.commit.record.text -match \"\\#\\w+\"}={\n if ($psStyle.FormatHyperlink) {\n $psStyle.FormatHyperlink($matches.0, \"https://bsky.app/search?q=$([Web.HttpUtility]::UrlEncode($matches.0))\")\n } else {\n $matches.0\n }\n }\n}" }, { - "Title": "EXAMPLE 10", + "Title": "EXAMPLE 11", "Markdown": "", "Code": "websocket wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=app.bsky.feed.post -WatchFor @{\n {$args.commit.record.text -match \"\\#\\w+\"}={\n $matches.0\n }\n {$args.commit.record.text -match '[\\p{IsHighSurrogates}\\p{IsLowSurrogates}]+'}={\n $matches.0\n }\n}" }, { - "Title": "EXAMPLE 11", + "Title": "EXAMPLE 12", "Markdown": "We can decorate a type returned from a WebSocket, allowing us to add additional properties.\nFor example, let's add a `Tags` property to the `app.bsky.feed.post` type.", "Code": "$typeName = 'app.bsky.feed.post'\nUpdate-TypeData -TypeName $typeName -MemberName 'Tags' -MemberType ScriptProperty -Value {\n @($this.commit.record.facets.features.tag)\n} -Force\n\n# Now, let's get 10kb posts ( this should not take too long )\n$somePosts =\n websocket \"wss://jetstream2.us-west.bsky.network/subscribe?wantedCollections=$typeName\" -PSTypeName $typeName -Maximum 10kb -Watch\n$somePosts |\n ? Tags |\n Select -ExpandProperty Tags |\n Group |\n Sort Count -Descending |\n Select -First 10" } diff --git a/docs/_data/LastDateBuilt.json b/docs/_data/LastDateBuilt.json index a7d8667..b0f9097 100644 --- a/docs/_data/LastDateBuilt.json +++ b/docs/_data/LastDateBuilt.json @@ -1 +1 @@ -"2024-12-20" \ No newline at end of file +"2025-03-22" \ No newline at end of file