diff --git a/docs/Tutorials/WebSockets.md b/docs/Tutorials/WebSockets.md index b5a733a16..72a963b5a 100644 --- a/docs/Tutorials/WebSockets.md +++ b/docs/Tutorials/WebSockets.md @@ -1,9 +1,6 @@ # Web Sockets -Pode has support for server-to-client communications using WebSockets, including secure WebSockets. - -!!! note - Currently only broadcasting messages to connected clients/browsers from the server is supported. Client-to-server communications is in the works! +Pode has support for using WebSockets, including secure WebSockets, for either server-to-client or vice-versa. WebSockets allow you to send messages directly from your server to connected clients. This allows you to get real-time continuous updates for the frontend without having to constantly refresh the page, or by using async javascript! @@ -11,7 +8,7 @@ WebSockets allow you to send messages directly from your server to connected cli ### Listening -On the server side, the only real work required is to register a new endpoint to listen on. To do this you can use the normal [`Add-PodeEndpoint`](../../Functions/Core/Add-PodeEndpoint) function, but with a protocol of either `Ws` or `Wss`: +On the server side, the only real work required is to register a new endpoint to listen on. To do this you can use the normal [`Add-PodeEndpoint`](../../Functions/Core/Add-PodeEndpoint), but with a protocol of either `Ws` or `Wss`: ```powershell Add-PodeEndpoint -Address * -Port 8091 -Protocol Ws @@ -22,7 +19,7 @@ Add-PodeEndpoint -Address * -Port 8091 -Certificate './path/cert.pfx' -Certifica ### Broadcasting -To broadcast a message from the server to all connected clients you can use the [`Send-PodeSignal`](../../Functions/Responses/Send-PodeSignal) function. You can either send raw JSON data, or you can pass a HashTable/PSObject and it will be converted to JSON for you. +To broadcast a message from the server to all connected clients you can use [`Send-PodeSignal`](../../Functions/Responses/Send-PodeSignal). You can either send raw JSON data, or you can pass a HashTable/PSObject and it will be converted to JSON for you. To broadcast some data to all clients from a POST route, you could use the following. This will get some message from one of the clients, and then broadcast it to every other client: @@ -45,6 +42,8 @@ You can also broadcast messages from Timers, or from Schedules. ## Client Side +### Receiving + On the client side, you need to use javascript to register a WebSocket and then bind the `onmessage` event to do something when a broadcasted message is received. To create a WebSocket, you can do something like the following which will bind a WebSocket onto the root path '/': @@ -62,6 +61,22 @@ $(document).ready(() => { }) ``` +### Sending + +To send a message using the WebSocket, you can use the `.send` function. When you send a message from client-to-server, the data must be a JSON value containing the `message`, `path`, and `clientId`. Only the `message` is mandatory. + +For example, if you have a form with input, you can send the message as follows: + +```javascript +$('#form').submit(function(e) { + e.preventDefault(); + ws.send(JSON.stringify({ message: $('#input').val() })); + $('#input').val(''); +}) +``` + +This will send the message to the server, which will in-turn broadcast to all other clients. + ## Full Example > This full example is a cut-down version of the one found in `/examples/web-signal.ps1` of the main repository. @@ -78,10 +93,7 @@ server.ps1 script.js ``` -The following is the Pode server code, that will create two routes. - -* The first route will be for some home page, with a button/input for broadcasting messages. -* The second route will be invoked when the button above is clicked. It will then broadcast some message to all clients. +The following is the Pode server code, that will create one route, which will be for some home page, with a button/input for broadcasting messages. ```powershell Start-PodeServer { @@ -94,12 +106,6 @@ Start-PodeServer { Add-PodeRoute -Method Get -Path '/' -ScriptBlock { Write-PodeViewResponse -Path 'index' } - - # broadcast a received message back out to ever connected client via websockets - Add-PodeRoute -Method Post -Path '/broadcast' -ScriptBlock { - param($e) - Send-PodeSignal -Value @{ Message = $e.Data['message'] } - } } ``` @@ -124,7 +130,7 @@ Next we have the HTML web page with a basic button/input for broadcasting messag ``` -Finally, the following is the client-side javascript to register a WebSocket for the client. It will also invoke the `/broadcast` endpoint when the button is clicked: +Finally, the following is the client-side javascript to register a WebSocket for the client. It will also invoke the `.send` function of the WebSocket when the button is clicked: ```javascript $(document).ready(() => { @@ -132,22 +138,19 @@ $(document).ready(() => { $('#bc-form').submit(function(e) { e.preventDefault(); - $.ajax({ - url: '/broadcast', - type: 'post', - data: $('#bc-form').serialize() - }) + ws.send(JSON.stringify({ + message: $('input[name=message]').val() + })); - $('input[name=message]').val('') - }) + $('input[name=message]').val(''); + }); // create the websocket var ws = new WebSocket("ws://localhost:8091/"); // event for inbound messages to append them ws.onmessage = function(evt) { - var data = JSON.parse(evt.data) - $('#messages').append(`

${data.Message}

`); + $('#messages').append(`

${evt.data}

`); } -}) +}); ``` diff --git a/examples/public/scripts/websockets.js b/examples/public/scripts/websockets.js index 10c1b84ae..a68a411bc 100644 --- a/examples/public/scripts/websockets.js +++ b/examples/public/scripts/websockets.js @@ -1,23 +1,18 @@ $(document).ready(() => { - // bind submit on the form to send message to the server - $('#bc-form').submit(function(e) { - e.preventDefault(); - - $.ajax({ - url: '/broadcast', - type: 'post', - data: $('#bc-form').serialize() - }) - - $('input[name=message]').val('') - }) - // create the websocket var ws = new WebSocket("ws://localhost:8091/"); // event for inbound messages to append them ws.onmessage = function(evt) { - var data = JSON.parse(evt.data) - $('#messages').append(`

${data.Message}

`); + //var data = JSON.parse(evt.data) + console.log(evt.data); + $('#messages').append(`

${evt.data}

`); } + + // send message on the socket, to all clients + $('#bc-form').submit(function(e) { + e.preventDefault(); + ws.send(JSON.stringify({ message: $('#bc-message').val() })); + $('input[name=message]').val(''); + }) }) \ No newline at end of file diff --git a/examples/views/websockets.html b/examples/views/websockets.html index 264e711b4..115def91d 100644 --- a/examples/views/websockets.html +++ b/examples/views/websockets.html @@ -9,7 +9,7 @@

Example of using a WebSockets

Clicking submit will broadcast the message to all connected clients - try opening this page on multiple, and different, browsers!

- +
diff --git a/examples/web-signal.ps1 b/examples/web-signal.ps1 index 202fb9e4c..dbbad3b94 100644 --- a/examples/web-signal.ps1 +++ b/examples/web-signal.ps1 @@ -23,10 +23,4 @@ Start-PodeServer -Threads 5 { Add-PodeRoute -Method Get -Path '/' -ScriptBlock { Write-PodeViewResponse -Path 'websockets' } - - # POST broadcast a received message back out to ever connected client via websockets - Add-PodeRoute -Method Post -Path '/broadcast' -ScriptBlock { - param($e) - Send-PodeSignal -Value @{ Message = $e.Data['message'] } - } } \ No newline at end of file diff --git a/src/Listener/PodeClientSignal.cs b/src/Listener/PodeClientSignal.cs new file mode 100644 index 000000000..902bbe647 --- /dev/null +++ b/src/Listener/PodeClientSignal.cs @@ -0,0 +1,18 @@ +using System; + +namespace Pode +{ + public class PodeClientSignal + { + public PodeWebSocket WebSocket { get; private set; } + public string Message { get; private set; } + public DateTime Timestamp { get; private set; } + + public PodeClientSignal(PodeWebSocket webSocket, string message) + { + WebSocket = webSocket; + Message = message; + Timestamp = DateTime.UtcNow; + } + } +} \ No newline at end of file diff --git a/src/Listener/PodeContext.cs b/src/Listener/PodeContext.cs index ea445db63..ad329bf67 100644 --- a/src/Listener/PodeContext.cs +++ b/src/Listener/PodeContext.cs @@ -30,11 +30,21 @@ public bool IsWebSocket get => (Type == PodeContextType.WebSocket); } + public bool IsWebSocketUpgraded + { + get => (IsWebSocket && Request is PodeWsRequest); + } + public bool IsSmtp { get => (Type == PodeContextType.Smtp); } + public bool IsHttp + { + get => (Type == PodeContextType.Http); + } + public PodeSmtpRequest SmtpRequest { get => (PodeSmtpRequest)Request; @@ -45,6 +55,11 @@ public PodeHttpRequest HttpRequest get => (PodeHttpRequest)Request; } + public PodeWsRequest WsRequest + { + get => (PodeWsRequest)Request; + } + public bool IsKeepAlive { get => (Request.IsKeepAlive); @@ -134,15 +149,20 @@ private void SetContextType() case PodeListenerType.WebSocket: if (!HttpRequest.IsWebSocket) { - throw new HttpRequestException("Request is not for a websocket"); + throw new HttpRequestException("Request is not for a WebSocket"); } Type = PodeContextType.WebSocket; break; - // - only allow http, with upgrade to web-socket + // - only allow http case PodeListenerType.Http: - Type = HttpRequest.IsWebSocket ? PodeContextType.WebSocket : PodeContextType.Http; + if (HttpRequest.IsWebSocket) + { + throw new HttpRequestException("Request is not Http"); + } + + Type = PodeContextType.Http; break; } } @@ -209,7 +229,16 @@ public void UpgradeWebSocket(string clientId = null) Response.Send(); // add open web socket to listener - Listener.AddWebSocket(new PodeWebSocket(this, HttpRequest.Url.AbsolutePath, clientId)); + var webSocket = new PodeWebSocket(this, HttpRequest.Url.AbsolutePath, clientId); + + var wsRequest = new PodeWsRequest(HttpRequest); + wsRequest.WebSocket = webSocket; + Request = wsRequest; + + Listener.AddWebSocket(WsRequest.WebSocket); + + // HttpRequest.WebSocket = new PodeWebSocket(this, HttpRequest.Url.AbsolutePath, clientId); + // Listener.AddWebSocket(HttpRequest.WebSocket); } public void Dispose() @@ -227,7 +256,8 @@ public void Dispose(bool force) Response.StatusCode = 500; } - if (!IsSmtp && State != PodeContextState.SslError) + // only send a response if Http + if (IsHttp && State != PodeContextState.SslError) { Response.Send(); } @@ -238,13 +268,13 @@ public void Dispose(bool force) SmtpRequest.Reset(); } - Response.Dispose(); - if (!IsKeepAlive || force) { State = PodeContextState.Closed; Request.Dispose(); } + + Response.Dispose(); } catch {} diff --git a/src/Listener/PodeHttpRequest.cs b/src/Listener/PodeHttpRequest.cs index b461880dc..ad156d3f2 100644 --- a/src/Listener/PodeHttpRequest.cs +++ b/src/Listener/PodeHttpRequest.cs @@ -2,13 +2,8 @@ using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; -using System.IO; -using System.Net; using System.Net.Http; -using System.Net.Security; using System.Net.Sockets; -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.RegularExpressions; using System.Web; @@ -31,7 +26,12 @@ public class PodeHttpRequest : PodeRequest public string Body { get; private set; } public byte[] RawBody { get; private set; } public string Host { get; private set; } - public bool IsWebSocket { get; private set; } + + private bool _isWebSocket = false; + public bool IsWebSocket + { + get => _isWebSocket; + } public override bool CloseImmediately { @@ -201,12 +201,13 @@ protected override void Parse(byte[] bytes) UserAgent = $"{Headers["User-Agent"]}"; ContentType = $"{Headers["Content-Type"]}"; - // keep-alive? - IsKeepAlive = (Headers.ContainsKey("Connection") - && $"{Headers["Connection"]}".Equals("keep-alive", StringComparison.InvariantCultureIgnoreCase)); - // is web-socket? - IsWebSocket = Headers.ContainsKey("Sec-WebSocket-Key"); + _isWebSocket = Headers.ContainsKey("Sec-WebSocket-Key"); + + // keep-alive? + IsKeepAlive = (_isWebSocket || + (Headers.ContainsKey("Connection") + && $"{Headers["Connection"]}".Equals("keep-alive", StringComparison.InvariantCultureIgnoreCase))); // set content encoding ContentEncoding = System.Text.Encoding.UTF8; diff --git a/src/Listener/PodeListener.cs b/src/Listener/PodeListener.cs index bb94f75b8..f53a11bda 100644 --- a/src/Listener/PodeListener.cs +++ b/src/Listener/PodeListener.cs @@ -16,7 +16,8 @@ public class PodeListener : IDisposable private IList Sockets; private BlockingCollection Contexts; - private BlockingCollection Signals; + private BlockingCollection ServerSignals; + private BlockingCollection ClientSignals; public PodeListener(CancellationToken cancellationToken, PodeListenerType type = PodeListenerType.Http) { @@ -26,7 +27,8 @@ public PodeListener(CancellationToken cancellationToken, PodeListenerType type = Sockets = new List(); WebSockets = new Dictionary(); Contexts = new BlockingCollection(); - Signals = new BlockingCollection(); + ServerSignals = new BlockingCollection(); + ClientSignals = new BlockingCollection(); } public void Add(PodeSocket socket) @@ -68,21 +70,39 @@ public void AddWebSocket(PodeWebSocket webSocket) } } - public PodeSignal GetSignal(CancellationToken cancellationToken) + public PodeServerSignal GetServerSignal(CancellationToken cancellationToken) { - return Signals.Take(cancellationToken); + return ServerSignals.Take(cancellationToken); } - public Task GetSignalAsync(CancellationToken cancellationToken) + public Task GetServerSignalAsync(CancellationToken cancellationToken) { - return Task.Factory.StartNew(() => GetSignal(cancellationToken), cancellationToken); + return Task.Factory.StartNew(() => GetServerSignal(cancellationToken), cancellationToken); } - public void AddSignal(string value, string path, string clientId) + public void AddServerSignal(string value, string path, string clientId) { - lock (Signals) + lock (ServerSignals) { - Signals.Add(new PodeSignal(value, path, clientId)); + ServerSignals.Add(new PodeServerSignal(value, path, clientId)); + } + } + + public PodeClientSignal GetClientSignal(CancellationToken cancellationToken) + { + return ClientSignals.Take(cancellationToken); + } + + public Task GetClientSignalAsync(CancellationToken cancellationToken) + { + return Task.Factory.StartNew(() => GetClientSignal(cancellationToken), cancellationToken); + } + + public void AddClientSignal(PodeClientSignal signal) + { + lock (ClientSignals) + { + ClientSignals.Add(signal); } } diff --git a/src/Listener/PodeRequest.cs b/src/Listener/PodeRequest.cs index 8ff82b779..3efb38a73 100644 --- a/src/Listener/PodeRequest.cs +++ b/src/Listener/PodeRequest.cs @@ -31,6 +31,17 @@ public PodeRequest(Socket socket) RemoteEndPoint = socket.RemoteEndPoint; } + public PodeRequest(PodeRequest request) + { + IsSsl = request.IsSsl; + InputStream = request.InputStream; + IsKeepAlive = request.IsKeepAlive; + Socket = request.Socket; + RemoteEndPoint = Socket.RemoteEndPoint; + Error = request.Error; + Context = request.Context; + } + public void Open(X509Certificate certificate, SslProtocols protocols) { // ssl or not? @@ -97,7 +108,7 @@ public void SetContext(PodeContext context) Context = context; } - public void Dispose() + public virtual void Dispose() { if (Socket != default(Socket)) { diff --git a/src/Listener/PodeResponse.cs b/src/Listener/PodeResponse.cs index 29289e9f7..8fab0dfd8 100644 --- a/src/Listener/PodeResponse.cs +++ b/src/Listener/PodeResponse.cs @@ -105,24 +105,30 @@ public void Send() } } - public void SendSignal(PodeSignal signal) + public void SendSignal(PodeServerSignal signal) { Write(signal.Value); } public void Write(string message, bool flush = false) { - var msgBytes = Encoding.GetBytes(message); - // simple messages if (!Context.IsWebSocket) { - Write(msgBytes, flush); - return; + Write(Encoding.GetBytes(message), flush); } // web socket message - var buffer = new List() { (byte)((byte)0x80 | (byte)1) }; + else + { + WriteFrame(message, PodeWsOpCode.Text, flush); + } + } + + public void WriteFrame(string message, PodeWsOpCode opCode = PodeWsOpCode.Text, bool flush = false) + { + var msgBytes = Encoding.GetBytes(message); + var buffer = new List() { (byte)((byte)0x80 | (byte)opCode) }; if (msgBytes.Length < 126) { diff --git a/src/Listener/PodeSignal.cs b/src/Listener/PodeServerSignal.cs similarity index 78% rename from src/Listener/PodeSignal.cs rename to src/Listener/PodeServerSignal.cs index 4739e5913..020ee8511 100644 --- a/src/Listener/PodeSignal.cs +++ b/src/Listener/PodeServerSignal.cs @@ -2,14 +2,14 @@ namespace Pode { - public class PodeSignal + public class PodeServerSignal { public string Value { get; private set; } public string Path { get; private set; } public string ClientId { get; private set; } public DateTime Timestamp { get; private set; } - public PodeSignal(string value, string path, string clientId) + public PodeServerSignal(string value, string path, string clientId) { Value = value; Path = path; diff --git a/src/Listener/PodeSocket.cs b/src/Listener/PodeSocket.cs index 527ae56b7..88a069ffc 100644 --- a/src/Listener/PodeSocket.cs +++ b/src/Listener/PodeSocket.cs @@ -214,11 +214,12 @@ private void ProcessReceive(SocketAsyncEventArgs args) process = false; } - // if it's a websocket, upgrade it - else if (context.IsWebSocket) + // if it's a websocket, upgrade it, then add context back for re-receiving + else if (context.IsWebSocket && !context.IsWebSocketUpgraded) { context.UpgradeWebSocket(); process = false; + context.Dispose(); } // if it's an email, re-receive unless processable @@ -234,7 +235,15 @@ private void ProcessReceive(SocketAsyncEventArgs args) // add the context for processing if (process) { - Listener.AddContext(context); + if (context.IsWebSocket) + { + Listener.AddClientSignal(context.WsRequest.NewClientSignal()); + context.Dispose(); + } + else + { + Listener.AddContext(context); + } } } catch (Exception ex) diff --git a/src/Listener/PodeWsOpCode.cs b/src/Listener/PodeWsOpCode.cs new file mode 100644 index 000000000..13f441e6b --- /dev/null +++ b/src/Listener/PodeWsOpCode.cs @@ -0,0 +1,12 @@ +namespace Pode +{ + public enum PodeWsOpCode + { + Fragment = 0, + Text = 1, + Binary = 2, + Close = 8, + Ping = 9, + Pong = 10 + } +} \ No newline at end of file diff --git a/src/Listener/PodeWsRequest.cs b/src/Listener/PodeWsRequest.cs new file mode 100644 index 000000000..e8384f41a --- /dev/null +++ b/src/Listener/PodeWsRequest.cs @@ -0,0 +1,118 @@ +using System; +using System.Net.WebSockets; + +namespace Pode +{ + public class PodeWsRequest : PodeRequest + { + public PodeWsOpCode OpCode { get; private set; } + public string Body { get; private set; } + public byte[] RawBody { get; private set; } + public PodeWebSocket WebSocket { get; set; } + + private WebSocketCloseStatus _closeStatus = WebSocketCloseStatus.Empty; + public WebSocketCloseStatus CloseStatus + { + get => _closeStatus; + } + + private string _closeDescription = string.Empty; + public string CloseDescription + { + get => _closeDescription; + } + + public override bool CloseImmediately + { + get => (OpCode == PodeWsOpCode.Close); + } + + public PodeWsRequest(PodeRequest request) + : base(request) + { + IsKeepAlive = true; + } + + public PodeClientSignal NewClientSignal() + { + return new PodeClientSignal(WebSocket, Body); + } + + protected override void Parse(byte[] bytes) + { + // get the length and op-code + var dataLength = bytes[1] - 128; + OpCode = (PodeWsOpCode)(bytes[0] & 0b00001111); + var offset = 0; + + // set the offset relevant to the data's length + if (dataLength < 126) + { + offset = 2; + } + else if (dataLength == 126) + { + dataLength = BitConverter.ToInt16(new byte[] { bytes[3], bytes[2] }, 0); + offset = 4; + } + else + { + dataLength = (int)BitConverter.ToInt64(new byte[] { bytes[9], bytes[8], bytes[7], bytes[6], bytes[5], bytes[4], bytes[3], bytes[2] }, 0); + offset = 10; + } + + // read in the mask + var mask = new byte[] { bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3] }; + offset += 4; + + // build the decoded message + var decoded = new byte[dataLength]; + for (var i = 0; i < dataLength; ++i) + { + decoded[i] = (byte)(bytes[offset + i] ^ mask[i % 4]); + } + + // set the raw/body + RawBody = bytes; + Body = Encoding.GetString(decoded); + + // get the close status and description + if (OpCode == PodeWsOpCode.Close) + { + _closeStatus = WebSocketCloseStatus.Empty; + _closeDescription = string.Empty; + + if (dataLength >= 2) + { + Array.Reverse(decoded, 0, 2); + var code = (int)BitConverter.ToUInt16(decoded, 0); + + _closeStatus = Enum.IsDefined(typeof(WebSocketCloseStatus), code) + ? (WebSocketCloseStatus)code + : WebSocketCloseStatus.Empty; + + var descCount = dataLength - 2; + if (descCount > 0) + { + _closeDescription = Encoding.GetString(decoded, 2, descCount); + } + } + } + + // send back a pong + if (OpCode == PodeWsOpCode.Ping) + { + Context.Response.WriteFrame(string.Empty, PodeWsOpCode.Pong); + } + } + + public override void Dispose() + { + // send close frame, remove client, and dispose + Context.Response.WriteFrame(string.Empty, PodeWsOpCode.Close); + Context.Listener.WebSockets.Remove(WebSocket.ClientId); + base.Dispose(); + } + + } +} \ No newline at end of file diff --git a/src/Private/SignalServer.ps1 b/src/Private/SignalServer.ps1 index 641288b26..dcd9d7bca 100644 --- a/src/Private/SignalServer.ps1 +++ b/src/Private/SignalServer.ps1 @@ -53,7 +53,7 @@ function Start-PodeSignalServer try { while ($Listener.IsListening -and !$PodeContext.Tokens.Cancellation.IsCancellationRequested) { - $message = (Wait-PodeTask -Task $Listener.GetSignalAsync($PodeContext.Tokens.Cancellation.Token)) + $message = (Wait-PodeTask -Task $Listener.GetServerSignalAsync($PodeContext.Tokens.Cancellation.Token)) # get the sockets for the message $sockets = @() @@ -87,7 +87,7 @@ function Start-PodeSignalServer $socket.Context.Response.SendSignal($message) } catch { - $Listener.WebSockets.Remove($socket.ClientId) | Out-Nul + $Listener.WebSockets.Remove($socket.ClientId) | Out-Null } } } @@ -102,6 +102,32 @@ function Start-PodeSignalServer Add-PodeRunspace -Type 'Signals' -ScriptBlock $signalScript -Parameters @{ 'Listener' = $listener } + # script to queue messages from clients to send back to other clients from the server + $clientScript = { + param( + [Parameter(Mandatory=$true)] + [ValidateNotNull()] + $Listener + ) + + try { + while ($Listener.IsListening -and !$PodeContext.Tokens.Cancellation.IsCancellationRequested) + { + $context = (Wait-PodeTask -Task $Listener.GetClientSignalAsync($PodeContext.Tokens.Cancellation.Token)) + $context = ($context.Message | ConvertFrom-Json) + Send-PodeSignal -Value $context.message -Path $context.path -ClientId $context.clientId + } + } + catch [System.OperationCanceledException] {} + catch { + $_ | Write-PodeErrorLog + $_.Exception | Write-PodeErrorLog -CheckInnerException + throw $_.Exception + } + } + + Add-PodeRunspace -Type 'Signals' -ScriptBlock $clientScript -Parameters @{ 'Listener' = $listener } + # script to keep web server listening until cancelled $waitScript = { param( diff --git a/src/Public/Responses.ps1 b/src/Public/Responses.ps1 index f099af0f0..c34673592 100644 --- a/src/Public/Responses.ps1 +++ b/src/Public/Responses.ps1 @@ -1340,5 +1340,5 @@ function Send-PodeSignal } } - $PodeContext.Server.WebSockets.Listener.AddSignal($Value, $Path, $ClientId) + $PodeContext.Server.WebSockets.Listener.AddServerSignal($Value, $Path, $ClientId) } \ No newline at end of file