diff --git a/Jung.SimpleWebSocket.sln b/Jung.SimpleWebSocket.sln index ed5da37..fec2fcb 100644 --- a/Jung.SimpleWebSocket.sln +++ b/Jung.SimpleWebSocket.sln @@ -3,9 +3,25 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.11.35222.181 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jung.SimpleWebSocket", "Jung.SimpleWebSocket\Jung.SimpleWebSocket.csproj", "{793B04E9-6326-425A-A29C-A736CFD1E0C0}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jung.SimpleWebSocketTest", "Jung.SimpleWebSocketTest\Jung.SimpleWebSocketTest.csproj", "{26725C3C-8E90-49AC-9EE4-2A77ADB2229D}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{0487DC39-481D-4828-81A5-58CF9BCA2E98}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{68AB7986-ED88-4C74-A447-934ED6D1B657}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jung.SimpleWebSocket", "src\Jung.SimpleWebSocket\Jung.SimpleWebSocket.csproj", "{793B04E9-6326-425A-A29C-A736CFD1E0C0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jung.SimpleWebSocket.UnitTests", "tests\Jung.SimpleWebSocket.UnitTests\Jung.SimpleWebSocket.UnitTests.csproj", "{26725C3C-8E90-49AC-9EE4-2A77ADB2229D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jung.SimpleWebSocket.IntegrationTests", "tests\Jung.SimpleWebSocket.IntegrationTests\Jung.SimpleWebSocket.IntegrationTests.csproj", "{D052400A-9F1E-4F2E-98B9-AF74A7A16A2F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BasicServerExample", "examples\BasicServerExample\BasicServerExample.csproj", "{0C73E461-DE3D-4D14-B81B-732B7C6971A1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BasicClientExample", "examples\BasicClientExample\BasicClientExample.csproj", "{9D4AD09E-B6FF-4E2A-894E-49B97729E190}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BasicUserHandlingServerExample", "examples\BasicUserHandlingServerExample\BasicUserHandlingServerExample.csproj", "{A538895A-481B-44A5-8E6F-6D617C3F5378}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BasicUserHandlingClientExample", "examples\BasicUserHandlingClientExample\BasicUserHandlingClientExample.csproj", "{C79EBA14-EFA6-424D-9C6E-609C98994473}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -21,10 +37,39 @@ Global {26725C3C-8E90-49AC-9EE4-2A77ADB2229D}.Debug|Any CPU.Build.0 = Debug|Any CPU {26725C3C-8E90-49AC-9EE4-2A77ADB2229D}.Release|Any CPU.ActiveCfg = Release|Any CPU {26725C3C-8E90-49AC-9EE4-2A77ADB2229D}.Release|Any CPU.Build.0 = Release|Any CPU + {D052400A-9F1E-4F2E-98B9-AF74A7A16A2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D052400A-9F1E-4F2E-98B9-AF74A7A16A2F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D052400A-9F1E-4F2E-98B9-AF74A7A16A2F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D052400A-9F1E-4F2E-98B9-AF74A7A16A2F}.Release|Any CPU.Build.0 = Release|Any CPU + {0C73E461-DE3D-4D14-B81B-732B7C6971A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C73E461-DE3D-4D14-B81B-732B7C6971A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C73E461-DE3D-4D14-B81B-732B7C6971A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C73E461-DE3D-4D14-B81B-732B7C6971A1}.Release|Any CPU.Build.0 = Release|Any CPU + {9D4AD09E-B6FF-4E2A-894E-49B97729E190}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D4AD09E-B6FF-4E2A-894E-49B97729E190}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D4AD09E-B6FF-4E2A-894E-49B97729E190}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D4AD09E-B6FF-4E2A-894E-49B97729E190}.Release|Any CPU.Build.0 = Release|Any CPU + {A538895A-481B-44A5-8E6F-6D617C3F5378}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A538895A-481B-44A5-8E6F-6D617C3F5378}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A538895A-481B-44A5-8E6F-6D617C3F5378}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A538895A-481B-44A5-8E6F-6D617C3F5378}.Release|Any CPU.Build.0 = Release|Any CPU + {C79EBA14-EFA6-424D-9C6E-609C98994473}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C79EBA14-EFA6-424D-9C6E-609C98994473}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C79EBA14-EFA6-424D-9C6E-609C98994473}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C79EBA14-EFA6-424D-9C6E-609C98994473}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {793B04E9-6326-425A-A29C-A736CFD1E0C0} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {26725C3C-8E90-49AC-9EE4-2A77ADB2229D} = {68AB7986-ED88-4C74-A447-934ED6D1B657} + {D052400A-9F1E-4F2E-98B9-AF74A7A16A2F} = {68AB7986-ED88-4C74-A447-934ED6D1B657} + {0C73E461-DE3D-4D14-B81B-732B7C6971A1} = {0487DC39-481D-4828-81A5-58CF9BCA2E98} + {9D4AD09E-B6FF-4E2A-894E-49B97729E190} = {0487DC39-481D-4828-81A5-58CF9BCA2E98} + {A538895A-481B-44A5-8E6F-6D617C3F5378} = {0487DC39-481D-4828-81A5-58CF9BCA2E98} + {C79EBA14-EFA6-424D-9C6E-609C98994473} = {0487DC39-481D-4828-81A5-58CF9BCA2E98} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5F0E3FEC-7DDE-4E02-941B-CEF2DE33DB1C} EndGlobalSection diff --git a/Jung.SimpleWebSocket/Models/EventArguments/ClientDisconnectedArgs.cs b/Jung.SimpleWebSocket/Models/EventArguments/ClientDisconnectedArgs.cs deleted file mode 100644 index fe16b81..0000000 --- a/Jung.SimpleWebSocket/Models/EventArguments/ClientDisconnectedArgs.cs +++ /dev/null @@ -1,11 +0,0 @@ -// This file is part of the Jung SimpleWebSocket project. -// The project is licensed under the MIT license. - -namespace Jung.SimpleWebSocket.Models.EventArguments; - -/// -/// Represents the arguments of the event when a client disconnects from the server. -/// -/// The description why the closing status was initiated. -/// The unique identifier of the client that disconnected from the server. -public record ClientDisconnectedArgs(string ClosingStatusDescription, string ClientId); \ No newline at end of file diff --git a/Jung.SimpleWebSocket/SimpleWebSocketServer.cs b/Jung.SimpleWebSocket/SimpleWebSocketServer.cs deleted file mode 100644 index 943ca4a..0000000 --- a/Jung.SimpleWebSocket/SimpleWebSocketServer.cs +++ /dev/null @@ -1,314 +0,0 @@ -// This file is part of the Jung SimpleWebSocket project. -// The project is licensed under the MIT license. - -using Jung.SimpleWebSocket.Contracts; -using Jung.SimpleWebSocket.Delegates; -using Jung.SimpleWebSocket.Exceptions; -using Jung.SimpleWebSocket.Models; -using Jung.SimpleWebSocket.Models.EventArguments; -using Jung.SimpleWebSocket.Wrappers; -using Microsoft.Extensions.Logging; -using System.Collections.Concurrent; -using System.Net; -using System.Net.WebSockets; -using System.Text; - -namespace Jung.SimpleWebSocket -{ - /// - /// A simple WebSocket server. - /// - public class SimpleWebSocketServer : IWebSocketServer, IDisposable - { - /// - public IPAddress LocalIpAddress { get; } - /// - public int Port { get; } - - /// - public event ClientConnectedEventHandler? ClientConnected; - /// - public event ClientDisconnectedEventHandler? ClientDisconnected; - /// - public event ClientMessageReceivedEventHandler? MessageReceived; - /// - public event ClientBinaryMessageReceivedEventHandler? BinaryMessageReceived; - - /// - /// A dictionary of active clients. - /// - private ConcurrentDictionary ActiveClients { get; } = []; - - /// - public string[] ClientIds => [.. ActiveClients.Keys]; - - /// - public int ClientCount => ActiveClients.Count; - - /// - /// Future: Handle passive (disconnected) clients, delete them after a period of time, configurate this behavior in the WebSocketServerOptions - /// - private ConcurrentDictionary PassiveClients { get; } = []; - - /// - public bool IsListening => _server?.IsListening ?? false; - - /// - /// A flag indicating whether the server is started. - /// - private bool _isStarted; - - /// - /// A flag indicating whether the server is shutting down. - /// - private bool _serverShuttingDown; - - /// - /// A cancellation token source to cancel the server. - /// - private CancellationTokenSource _cancellationTokenSource = new(); - - /// - /// The server that listens for incoming connection attempts. - /// - private ITcpListener? _server; - - /// - /// A logger to write internal log messages. - /// - private readonly ILogger? _logger; - - /// - /// Initializes a new instance of the class that listens - /// for incoming connection attempts on the specified local IP address and port number. - /// - /// A local ip address - /// A port on which to listen for incoming connection attempts - /// A logger to write internal log messages - public SimpleWebSocketServer(IPAddress localIpAddress, int port, ILogger? logger = null) - { - LocalIpAddress = localIpAddress; - Port = port; - _logger = logger; - } - - /// - /// Constructor for dependency injection (used in tests) - /// - /// A local ip address - /// A port on which to listen for incoming connection attempts - /// A wrapped tcp listener - /// >A logger to write internal log messages - internal SimpleWebSocketServer(IPAddress localIpAddress, int port, ITcpListener tcpListener, ILogger? logger = null) - { - LocalIpAddress = localIpAddress; - Port = port; - _server = tcpListener; - _logger = logger; - } - - /// - public void Start(CancellationToken? cancellationToken = null) - { - if (_isStarted) throw new WebSocketServerException("Server is already started"); - _isStarted = true; - cancellationToken ??= CancellationToken.None; - - _cancellationTokenSource = new CancellationTokenSource(); - var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken.Value, _cancellationTokenSource.Token); - - _server ??= new TcpListenerWrapper(LocalIpAddress, Port); - _server.Start(); - _ = Task.Run(async delegate - { - _logger?.LogInformation("Server started at {LocalIpAddress}:{Port}", LocalIpAddress, Port); - while (!linkedTokenSource.IsCancellationRequested) - { - try - { - // Accept the client - var client = await _server.AcceptTcpClientAsync(linkedTokenSource.Token); - - _logger?.LogDebug("Client connected from {endpoint}", client.ClientConnection.RemoteEndPoint); - var clientAdded = ActiveClients.TryAdd(client.Id, client); - if (!clientAdded) - { - _logger?.LogError("Error while adding client to active clients, rejecting client."); - continue; - } - - _ = HandleClientAsync(client, linkedTokenSource.Token); - } - catch (OperationCanceledException) - { - // Ignore the exception, because it is thrown when cancellation is requested - } - catch (Exception exception) - { - _logger?.LogError(exception, "Error while accepting client."); - } - } - }, linkedTokenSource.Token); - } - - /// - public async Task ShutdownServer(CancellationToken? cancellationToken = null) - { - if (!_isStarted) throw new WebSocketServerException("Server is not started"); - if (_serverShuttingDown) throw new WebSocketServerException("Server is already shutting down"); - _serverShuttingDown = true; - - cancellationToken ??= CancellationToken.None; - var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken.Value, _cancellationTokenSource.Token); - - _logger?.LogInformation("Stopping server..."); - - // copying the active clients to avoid a collection modified exception - var activeClients = ActiveClients.Values.ToArray(); - foreach (var client in activeClients) - { - if (client.WebSocket != null && client.WebSocket.State == WebSocketState.Open) - { - await client.WebSocket.CloseAsync(WebSocketCloseStatus.EndpointUnavailable, "Server is shutting down", linkedTokenSource.Token); - ActiveClients.TryRemove(client.Id, out _); - client?.Dispose(); - } - } - - _cancellationTokenSource?.Cancel(); - _server?.Dispose(); - _server = null; - _logger?.LogInformation("Server stopped"); - } - - /// - public async Task SendMessageAsync(string clientId, string message, CancellationToken? cancellationToken = null) - { - // Find and check the client - if (!ActiveClients.TryGetValue(clientId, out var client)) throw new WebSocketServerException(message: "Client not found"); - if (client.WebSocket == null) throw new WebSocketServerException(message: "Client is not connected"); - - cancellationToken ??= CancellationToken.None; - var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken.Value, _cancellationTokenSource.Token); - - try - { - // Send the message - var buffer = Encoding.UTF8.GetBytes(message); - await client.WebSocket.SendAsync(new ArraySegment(buffer), WebSocketMessageType.Text, true, linkedTokenSource.Token); - _logger?.LogDebug("Message sent: {message}.", message); - } - catch (Exception exception) - { - _logger?.LogError(exception, "Error while sending a message."); - throw new WebSocketServerException(message: "An Error occurred sending a message.", innerException: exception); - } - } - - /// - /// - public WebSocketServerClient GetClientById(string clientId) - { - if (!ActiveClients.TryGetValue(clientId, out var client)) throw new WebSocketServerException(message: "Client not found"); - return client; - } - - /// - /// Handles the client connection. - /// - /// The client to handle - /// The cancellation token - /// A asynchronous task - private async Task HandleClientAsync(WebSocketServerClient client, CancellationToken cancellationToken) - { - try - { - // Upgrade the connection to a WebSocket - using var stream = client.ClientConnection.GetStream(); - var socketWrapper = new WebSocketUpgradeHandler(stream); - var request = await socketWrapper.AwaitContextAsync(cancellationToken); - await socketWrapper.AcceptWebSocketAsync(request, cancellationToken); - client.UpdateWebSocket(socketWrapper.CreateWebSocket(isServer: true)); - - _ = Task.Run(() => ClientConnected?.Invoke(this, new ClientConnectedArgs(client.Id)), cancellationToken); - - // Start listening for messages - _logger?.LogDebug("Connection upgraded, now listening on client {clientId}", client.Id); - await ProcessWebSocketMessagesAsync(client, cancellationToken); - } - catch (OperationCanceledException) - { - // Ignore the exception, because it is thrown when cancellation is requested - } - catch (Exception exception) - { - _logger?.LogError(exception, "Error while handling the client {clientId}", client.Id); - } - finally - { - if (!_serverShuttingDown) - { - ActiveClients.TryRemove(client.Id, out _); - client?.Dispose(); - } - } - } - - /// - /// Processes the WebSocket messages. - /// - /// The client whose messages to process - /// The cancellation token - /// A asynchronous task - /// - private async Task ProcessWebSocketMessagesAsync(WebSocketServerClient client, CancellationToken cancellationToken) - { - if (client.WebSocket == null) - { - throw new InvalidOperationException("WebSocket is not initialized"); - } - - var webSocket = client.WebSocket; - - var buffer = new byte[1024 * 4]; // Buffer for incoming data - while (webSocket.State == WebSocketState.Open) - { - cancellationToken.ThrowIfCancellationRequested(); - // Read the next message - WebSocketReceiveResult result = await webSocket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); - - if (result.MessageType == WebSocketMessageType.Text) - { - // Handle the text message - string receivedMessage = Encoding.UTF8.GetString(buffer, 0, result.Count); - _logger?.LogDebug("Message received: {message}", receivedMessage); - _ = Task.Run(() => MessageReceived?.Invoke(this, new ClientMessageReceivedArgs(receivedMessage, client.Id)), cancellationToken); - } - else if (result.MessageType == WebSocketMessageType.Binary) - { - // Handle the binary message - _logger?.LogDebug("Binary message received, length: {length} bytes", result.Count); - _ = Task.Run(() => BinaryMessageReceived?.Invoke(this, new ClientBinaryMessageReceivedArgs(buffer[..result.Count], client.Id)), cancellationToken); - } - // We have to check if the is shutting down here, - // because then we already sent the close message and we don't want to send another one - else if (result.MessageType == WebSocketMessageType.Close && !_serverShuttingDown) - { - _logger?.LogInformation("Received close message from client"); - _ = Task.Run(() => ClientDisconnected?.Invoke(this, new ClientDisconnectedArgs(result.CloseStatusDescription ?? string.Empty, client.Id)), cancellationToken); - await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None); - break; - } - } - } - - /// - public void Dispose() - { - _cancellationTokenSource?.Cancel(); - _server?.Dispose(); - _server = null; - GC.SuppressFinalize(this); - } - } -} \ No newline at end of file diff --git a/examples/BasicClientExample/BasicClientExample.csproj b/examples/BasicClientExample/BasicClientExample.csproj new file mode 100644 index 0000000..6467dee --- /dev/null +++ b/examples/BasicClientExample/BasicClientExample.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/examples/BasicClientExample/Program.cs b/examples/BasicClientExample/Program.cs new file mode 100644 index 0000000..fec5318 --- /dev/null +++ b/examples/BasicClientExample/Program.cs @@ -0,0 +1,59 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +using Jung.SimpleWebSocket; + +namespace BasicClientExample +{ + internal class Program + { + /// + /// An example of a basic WebSocket client using the Jung.SimpleWebSocket library. + /// + /// + static void Main(string[] args) + { + // Create the WebSocket client and connect to the server at ws://127.0.0.1:8085/chat + using var simpleWebSocketClient = new SimpleWebSocketClient("127.0.0.1", 8085, "/chat"); + + // Subscribe to client events + simpleWebSocketClient.Disconnected += (s, e) => Console.WriteLine($"Disconnected from the server. Reason: {e.ClosingStatusDescription}"); + simpleWebSocketClient.MessageReceived += (s, e) => Console.WriteLine($"Message received from server: {e.Message}"); + simpleWebSocketClient.BinaryMessageReceived += (s, e) => Console.WriteLine($"Binary message received from server: {BitConverter.ToString(e.Message)}"); + + try + { + // Connect to the server + simpleWebSocketClient.ConnectAsync().GetAwaiter().GetResult(); + + // Simulate any delay + Thread.Sleep(1000); + + // Send a message to the server + Console.WriteLine("Sending message to the server: Hello, Server!"); + simpleWebSocketClient.SendMessageAsync("Hello, Server!").GetAwaiter().GetResult(); + + // Keep the server running until a key is pressed + Console.WriteLine("Press Enter to stop the server..."); + Console.ReadKey(); + + // You do not have to explicitly disconnect the client because of the using statement + // simpleWebSocketClient.DisconnectAsync("Client is shutting down").Wait(); + } + catch (Exception exception) + { + + var exceptionMessage = exception.Message; + if (exception.InnerException != null) + { + exceptionMessage += $" Inner exception: {exception.InnerException.Message}"; + } + Console.WriteLine($"An error occurred: {exceptionMessage}"); + + // Keep the console application running until a key is pressed + Console.WriteLine("Press Enter close this window..."); + Console.ReadKey(); + } + } + } +} diff --git a/examples/BasicServerExample/BasicServerExample.csproj b/examples/BasicServerExample/BasicServerExample.csproj new file mode 100644 index 0000000..6467dee --- /dev/null +++ b/examples/BasicServerExample/BasicServerExample.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/examples/BasicServerExample/Program.cs b/examples/BasicServerExample/Program.cs new file mode 100644 index 0000000..c4f7934 --- /dev/null +++ b/examples/BasicServerExample/Program.cs @@ -0,0 +1,93 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +using Jung.SimpleWebSocket; +using Jung.SimpleWebSocket.Models; +using Jung.SimpleWebSocket.Models.EventArguments; + +namespace BasicServerExample +{ + internal class Program + { + /// + /// An example of a basic WebSocket server using the Jung.SimpleWebSocket library. + /// + /// + static void Main(string[] args) + { + // Create server options + var serverOptions = new SimpleWebSocketServerOptions() + { + // Set the server to listen on port 8085 and localhost + Port = 8085, + LocalIpAddress = new System.Net.IPAddress([127, 0, 0, 1]) + }; + + // Create the WebSocket server + using var simpleWebSocketServer = new SimpleWebSocketServer(serverOptions); + + // Subscribe to the server events + simpleWebSocketServer.ClientConnected += (s, e) => Console.WriteLine($"Client connected: {e.ClientId}"); + simpleWebSocketServer.ClientDisconnected += (s, e) => Console.WriteLine($"Client disconnected: {e.Client.Id}, Reason: {e.ClosingStatusDescription}"); + simpleWebSocketServer.MessageReceived += (s, e) => Console.WriteLine($"Message received from {e.ClientId}: {e.Message}"); + simpleWebSocketServer.BinaryMessageReceived += SimpleWebSocketServer_BinaryMessageReceived; + simpleWebSocketServer.ClientUpgradeRequestReceivedAsync += SimpleWebSocketServer_ClientUpgradeRequestReceivedAsync; + + // Start the server + simpleWebSocketServer.Start(); + Console.WriteLine("Server started on ws://127.0.0.1:8085"); + + // Keep the server running until a key is pressed + Console.WriteLine("Press Enter to stop the server..."); + Console.ReadKey(); + + // You do not have to explicitly shutdown the server because of the using statement + // simpleWebSocketServer.ShutdownServer().Wait(); + } + + /// + /// This event is triggered when a client sends a binary message to the server. + /// + /// The server that received the binary message. + /// The event arguments containing the client ID and the binary message. + private static void SimpleWebSocketServer_BinaryMessageReceived(object? sender, ClientBinaryMessageReceivedArgs e) + { + // Convert the binary message to a hex string + string hex = BitConverter.ToString(e.Message); + Console.WriteLine($"Binary message received from {e.ClientId}: {hex}"); + } + + /// + /// This event is triggered when a client sends an upgrade request to the server. + /// + /// The server that received the upgrade request. + /// The event arguments containing the client and the request details. + /// The cancellation token of the server. + /// A task that represents the asynchronous operation. + private static Task SimpleWebSocketServer_ClientUpgradeRequestReceivedAsync(object sender, ClientUpgradeRequestReceivedArgs e, CancellationToken cancellationToken) + { + Console.WriteLine($"Upgrade request received from {e.Client.Id} for {e.WebContext.RequestPath}"); + + // Do something with the upgrade request + // For example save request path to client properties + e.Client.Properties["RequestPath"] = e.WebContext.RequestPath; + + // Or reject the upgrade request if the request path is not /chat + if (e.WebContext.RequestPath != "/chat") + { + // It is recommended to set a status code higher than 400 to reject the upgrade request. + e.ResponseContext.StatusCode = System.Net.HttpStatusCode.Forbidden; // 403 Forbidden + // Handle the request and reject it + e.AcceptRequest = false; + } + else + { + // Handle the request and accept it + e.AcceptRequest = true; + } + + // Because this is an async event, we need to return a completed task. + return Task.CompletedTask; + } + } +} diff --git a/examples/BasicUserHandlingClientExample/BasicUserHandlingClientExample.csproj b/examples/BasicUserHandlingClientExample/BasicUserHandlingClientExample.csproj new file mode 100644 index 0000000..6467dee --- /dev/null +++ b/examples/BasicUserHandlingClientExample/BasicUserHandlingClientExample.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/examples/BasicUserHandlingClientExample/Program.cs b/examples/BasicUserHandlingClientExample/Program.cs new file mode 100644 index 0000000..66e3490 --- /dev/null +++ b/examples/BasicUserHandlingClientExample/Program.cs @@ -0,0 +1,77 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +using Jung.SimpleWebSocket; +using Jung.SimpleWebSocket.Models.EventArguments; + +namespace BasicUserHandlingClientExample +{ + internal class Program + { + /// + /// An example of a basic WebSocket client using the Jung.SimpleWebSocket library. + /// + /// + static void Main(string[] args) + { + // Create the WebSocket client and connect to the server at ws://127.0.0.1:8085/chat + using var simpleWebSocketClient = new SimpleWebSocketClient("127.0.0.1", 8085, "/chat"); + + // Subscribe to client events + simpleWebSocketClient.Disconnected += (s, e) => Console.WriteLine($"Disconnected from the server. Reason: {e.ClosingStatusDescription}"); + simpleWebSocketClient.MessageReceived += (s, e) => Console.WriteLine($"Message received from server: {e.Message}"); + simpleWebSocketClient.BinaryMessageReceived += (s, e) => Console.WriteLine($"Binary message received from server: {BitConverter.ToString(e.Message)}"); + simpleWebSocketClient.SendingUpgradeRequestAsync += SimpleWebSocketClient_SendingUpgradeRequestAsync; + + try + { + // Connect to the server + simpleWebSocketClient.ConnectAsync().GetAwaiter().GetResult(); + + // Simulate any delay + Thread.Sleep(1000); + + // Send a message to the server + Console.WriteLine("Sending message to the server: Hello, Server!"); + simpleWebSocketClient.SendMessageAsync("Hello, Server!").GetAwaiter().GetResult(); + + // Keep the server running until a key is pressed + Console.WriteLine("Press Enter to stop the server..."); + Console.ReadKey(); + + // You do not have to explicitly disconnect the client because of the using statement + // simpleWebSocketClient.DisconnectAsync("Client is shutting down").GetAwaiter().GetResult(); + } + catch (Exception exception) + { + var exceptionMessage = exception.Message; + if (exception.InnerException != null) + { + exceptionMessage += $" Inner exception: {exception.InnerException.Message}"; + } + Console.WriteLine($"An error occurred: {exceptionMessage}"); + + // Keep the console application running until a key is pressed + Console.WriteLine("Press Enter close this window..."); + Console.ReadKey(); + } + } + + /// + /// Handles the event triggered before a WebSocket upgrade request is sent, allowing customization of the + /// request. + /// + /// The source of the event, the WebSocket client instance. + /// The event arguments containing details about the upgrade request, including headers and other context. + /// The cancellation token of the client. + /// A completed task, as this method performs its operation synchronously. + private static Task SimpleWebSocketClient_SendingUpgradeRequestAsync(object sender, SendingUpgradeRequestArgs e, CancellationToken cancellationToken) + { + // Add a custom header to the upgrade request + e.WebContext.Headers["User-Name"] = "Alice"; + + // Because this is a synchronous method, we return a completed task. + return Task.CompletedTask; + } + } +} diff --git a/examples/BasicUserHandlingServerExample/BasicUserHandlingServerExample.csproj b/examples/BasicUserHandlingServerExample/BasicUserHandlingServerExample.csproj new file mode 100644 index 0000000..6467dee --- /dev/null +++ b/examples/BasicUserHandlingServerExample/BasicUserHandlingServerExample.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/examples/BasicUserHandlingServerExample/Program.cs b/examples/BasicUserHandlingServerExample/Program.cs new file mode 100644 index 0000000..2850237 --- /dev/null +++ b/examples/BasicUserHandlingServerExample/Program.cs @@ -0,0 +1,150 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +using Jung.SimpleWebSocket; +using Jung.SimpleWebSocket.Models; +using Jung.SimpleWebSocket.Models.EventArguments; +using System.Collections.Concurrent; + +namespace BasicUserHandlingServerExample +{ + internal class Program + { + // A thread-safe dictionary to store connected users + private static readonly ConcurrentDictionary _connectedUsers = []; + + /// + /// An example of a basic WebSocket server using the Jung.SimpleWebSocket library. + /// + /// + static void Main(string[] args) + { + // Create server options + var serverOptions = new SimpleWebSocketServerOptions() + { + // Set the server to listen on port 8085 and localhost + Port = 8085, + LocalIpAddress = new System.Net.IPAddress([127, 0, 0, 1]) + }; + + // Create the WebSocket server + using var simpleWebSocketServer = new SimpleWebSocketServer(serverOptions); + + // Subscribe to the server events + simpleWebSocketServer.ClientConnected += SimpleWebSocketServer_ClientConnected; + simpleWebSocketServer.ClientDisconnected += SimpleWebSocketServer_ClientDisconnected; + simpleWebSocketServer.MessageReceived += SimpleWebSocketServer_MessageReceived; + simpleWebSocketServer.BinaryMessageReceived += SimpleWebSocketServer_BinaryMessageReceived; + simpleWebSocketServer.ClientUpgradeRequestReceivedAsync += SimpleWebSocketServer_ClientUpgradeRequestReceivedAsync; + + // Start the server + simpleWebSocketServer.Start(); + Console.WriteLine($"Server started on ws://{serverOptions.LocalIpAddress}:{serverOptions.Port}"); + + // Keep the server running until a key is pressed + Console.WriteLine("Press Enter to stop the server..."); + Console.ReadKey(); + + // You do not have to explicitly shutdown the server because of the using statement + // simpleWebSocketServer.ShutdownServer().Wait(); + } + + /// + /// This event is triggered when a client successfully connects to the server. + /// + /// The server that the client connected to. + /// The event arguments containing the client ID. + private static void SimpleWebSocketServer_ClientConnected(object? sender, ClientConnectedArgs e) + { + if (((SimpleWebSocketServer)sender!).GetClientById(e.ClientId) is WebSocketServerClient client) + { + Console.WriteLine($"User name {client.Properties["UserName"]} connected successfully."); + } + } + + /// + /// This event is triggered when a client disconnects from the server. + /// + /// The server that the client disconnected from. + /// The event arguments containing the client and the disconnection details. + private static void SimpleWebSocketServer_ClientDisconnected(object? sender, ClientDisconnectedArgs e) + { + // Remove the user from the connected users list + var userName = e.Client.Properties["UserName"]?.ToString() ?? "Unknown"; + _connectedUsers.TryRemove(userName, out _); + Console.WriteLine($"User name {userName} disconnected."); + } + + + /// + /// This event is triggered when a text message is received from a client. + /// + /// The server that received the message. + /// The event arguments containing the client ID and the message. + private static void SimpleWebSocketServer_MessageReceived(object? sender, ClientMessageReceivedArgs e) + { + if (((SimpleWebSocketServer)sender!).GetClientById(e.ClientId) is WebSocketServerClient client) + { + Console.WriteLine($"Message received from {client.Properties["UserName"]}: {e.Message}"); + } + } + + /// + /// This event is triggered when a client sends a binary message to the server. + /// + /// The server that received the binary message. + /// The event arguments containing the client ID and the binary message. + private static void SimpleWebSocketServer_BinaryMessageReceived(object? sender, ClientBinaryMessageReceivedArgs e) + { + // Convert the binary message to a hex string + string hex = BitConverter.ToString(e.Message); + Console.WriteLine($"Binary message received from {e.ClientId}: {hex}"); + } + + /// + /// This event is triggered when a client sends an upgrade request to the server. + /// + /// The server that received the upgrade request. + /// The event arguments containing the client and the request details. + /// The cancellation token of the server. + /// A task that represents the asynchronous operation. + private static Task SimpleWebSocketServer_ClientUpgradeRequestReceivedAsync(object sender, ClientUpgradeRequestReceivedArgs e, CancellationToken cancellationToken) + { + Console.WriteLine($"Upgrade request received from {e.Client.Id} for {e.WebContext.RequestPath}"); + + // Get the user name from the request headers + var userName = e.WebContext.Headers["User-Name"]; + if (userName != null) + { + Console.WriteLine($"Request with user name: {userName}"); + + // Check if the user name is already connected + if (_connectedUsers.ContainsKey(userName)) + { + Console.WriteLine($"User name {userName} is already connected. Rejecting the upgrade request."); + e.ResponseContext.StatusCode = System.Net.HttpStatusCode.Conflict; // 409 Conflict + e.ResponseContext.BodyContent = "User name is already connected"; + e.AcceptRequest = false; + } + else + { + // Add the user name to the connected users list + _connectedUsers.TryAdd(userName, e.Client.Id); + // Store the user name in the client properties for future reference + e.Client.Properties["UserName"] = userName; + } + } + else + { + // It is recommended to set a status code higher than 400 to reject the upgrade request. + e.ResponseContext.StatusCode = System.Net.HttpStatusCode.BadRequest; // 400 Bad Request + e.ResponseContext.BodyContent = "Missing User-Name header"; + // Handle the request and reject it + e.AcceptRequest = false; + } + + // Because this is an async event, we need to return a completed task. + return Task.CompletedTask; + } + } +} diff --git a/Jung.SimpleWebSocket/AssemblyInfo.cs b/src/Jung.SimpleWebSocket/AssemblyInfo.cs similarity index 94% rename from Jung.SimpleWebSocket/AssemblyInfo.cs rename to src/Jung.SimpleWebSocket/AssemblyInfo.cs index dddbe37..44a7198 100644 --- a/Jung.SimpleWebSocket/AssemblyInfo.cs +++ b/src/Jung.SimpleWebSocket/AssemblyInfo.cs @@ -21,5 +21,5 @@ [assembly: Guid("ca34219d-7a2e-4993-ad9d-f27fda1bb9dc")] // Make internals visible to the test project and the dynamic proxy assembly (moq) -[assembly: InternalsVisibleTo("Jung.SimpleWebSocketTest")] +[assembly: InternalsVisibleTo("Jung.SimpleWebSocket.UnitTests")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] \ No newline at end of file diff --git a/Jung.SimpleWebSocket/Contracts/INetworkStream.cs b/src/Jung.SimpleWebSocket/Contracts/INetworkStream.cs similarity index 100% rename from Jung.SimpleWebSocket/Contracts/INetworkStream.cs rename to src/Jung.SimpleWebSocket/Contracts/INetworkStream.cs diff --git a/Jung.SimpleWebSocket/Contracts/ITcpClient.cs b/src/Jung.SimpleWebSocket/Contracts/ITcpClient.cs similarity index 100% rename from Jung.SimpleWebSocket/Contracts/ITcpClient.cs rename to src/Jung.SimpleWebSocket/Contracts/ITcpClient.cs diff --git a/Jung.SimpleWebSocket/Contracts/ITcpListener.cs b/src/Jung.SimpleWebSocket/Contracts/ITcpListener.cs similarity index 100% rename from Jung.SimpleWebSocket/Contracts/ITcpListener.cs rename to src/Jung.SimpleWebSocket/Contracts/ITcpListener.cs diff --git a/Jung.SimpleWebSocket/Contracts/IWebSocket.cs b/src/Jung.SimpleWebSocket/Contracts/IWebSocket.cs similarity index 100% rename from Jung.SimpleWebSocket/Contracts/IWebSocket.cs rename to src/Jung.SimpleWebSocket/Contracts/IWebSocket.cs diff --git a/Jung.SimpleWebSocket/Contracts/IWebSocketClient.cs b/src/Jung.SimpleWebSocket/Contracts/IWebSocketClient.cs similarity index 78% rename from Jung.SimpleWebSocket/Contracts/IWebSocketClient.cs rename to src/Jung.SimpleWebSocket/Contracts/IWebSocketClient.cs index 1a73240..9c00f62 100644 --- a/Jung.SimpleWebSocket/Contracts/IWebSocketClient.cs +++ b/src/Jung.SimpleWebSocket/Contracts/IWebSocketClient.cs @@ -2,6 +2,7 @@ // The project is licensed under the MIT license. using Jung.SimpleWebSocket.Delegates; +using Jung.SimpleWebSocket.Models.EventArguments; namespace Jung.SimpleWebSocket.Contracts; @@ -45,6 +46,15 @@ public interface IWebSocketClient : IDisposable /// event DisconnectedEventHandler? Disconnected; + /// + /// Occurs before an upgrade request is sent, allowing the request to be inspected or modified asynchronously. + /// + /// This event is triggered when an upgrade request is about to be sent. Subscribers can use this + /// event to inspect or modify the request by handling the parameter. The + /// event handler is asynchronous, so any modifications or operations should be performed within the provided + /// asynchronous context. + event AsyncEventHandler? SendingUpgradeRequestAsync; + /// /// Sends a message to all connected clients asynchronously. /// diff --git a/Jung.SimpleWebSocket/Contracts/IWebSocketServer.cs b/src/Jung.SimpleWebSocket/Contracts/IWebSocketServer.cs similarity index 65% rename from Jung.SimpleWebSocket/Contracts/IWebSocketServer.cs rename to src/Jung.SimpleWebSocket/Contracts/IWebSocketServer.cs index 4e9e79b..03d8b27 100644 --- a/Jung.SimpleWebSocket/Contracts/IWebSocketServer.cs +++ b/src/Jung.SimpleWebSocket/Contracts/IWebSocketServer.cs @@ -2,8 +2,10 @@ // The project is licensed under the MIT license. using Jung.SimpleWebSocket.Delegates; +using Jung.SimpleWebSocket.Exceptions; using Jung.SimpleWebSocket.Models; using Jung.SimpleWebSocket.Models.EventArguments; +using System.Diagnostics.CodeAnalysis; using System.Net; namespace Jung.SimpleWebSocket.Contracts; @@ -41,22 +43,27 @@ public interface IWebSocketServer : IDisposable /// /// Event that is raised when a client is connected. /// - event ClientConnectedEventHandler? ClientConnected; + event EventHandler? ClientConnected; /// /// Event that is raised when a client is disconnected. /// - event ClientDisconnectedEventHandler ClientDisconnected; + event EventHandler? ClientDisconnected; /// /// Event that is raised when a message is received from a client. /// - event ClientMessageReceivedEventHandler? MessageReceived; + event EventHandler? MessageReceived; /// /// Event that is raised when a binary message is received from a client. /// - event ClientBinaryMessageReceivedEventHandler? BinaryMessageReceived; + event EventHandler? BinaryMessageReceived; + + /// + /// Async Event that is raised when a client upgrade request is received. + /// + event AsyncEventHandler? ClientUpgradeRequestReceivedAsync; /// /// Gets a client by its id. @@ -65,6 +72,14 @@ public interface IWebSocketServer : IDisposable /// The client WebSocketServerClient GetClientById(string clientId); + /// + /// Attempts to get a client by its id. + /// + /// The id of the client + /// The client if found, otherwise null + /// if the client was found, otherwise ." + bool TryGetClientById(string clientId, [NotNullWhen(true)] out WebSocketServerClient? client); + /// /// Sends a message to all connected clients asynchronously. /// @@ -87,4 +102,13 @@ public interface IWebSocketServer : IDisposable /// The cancellation token. /// A task representing the asynchronous operation. void Start(CancellationToken? cancellationToken = null); + + /// + /// Changes the id of a client. + /// + /// The client to update + /// The new id of the client + /// Throws when the client is not found + /// Throws when the new id is already in use + void ChangeClientId(WebSocketServerClient client, string newId); } diff --git a/src/Jung.SimpleWebSocket/Delegates/AsyncEventHandler.cs b/src/Jung.SimpleWebSocket/Delegates/AsyncEventHandler.cs new file mode 100644 index 0000000..ba551fe --- /dev/null +++ b/src/Jung.SimpleWebSocket/Delegates/AsyncEventHandler.cs @@ -0,0 +1,14 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +namespace Jung.SimpleWebSocket.Delegates; + +/// +/// Represents an asynchronous event handler. +/// +/// The type of the event arguments. +/// The sender of the event. +/// The event arguments. +/// The cancellation token. +/// A task that represents the asynchronous operation. +public delegate Task AsyncEventHandler(object sender, TEventArgs e, CancellationToken cancellationToken) where TEventArgs : class; \ No newline at end of file diff --git a/Jung.SimpleWebSocket/Delegates/BinaryMessageReceivedEventHandler.cs b/src/Jung.SimpleWebSocket/Delegates/BinaryMessageReceivedEventHandler.cs similarity index 100% rename from Jung.SimpleWebSocket/Delegates/BinaryMessageReceivedEventHandler.cs rename to src/Jung.SimpleWebSocket/Delegates/BinaryMessageReceivedEventHandler.cs diff --git a/Jung.SimpleWebSocket/Delegates/ClientBinaryMessageReceivedEventHandler.cs b/src/Jung.SimpleWebSocket/Delegates/ClientBinaryMessageReceivedEventHandler.cs similarity index 100% rename from Jung.SimpleWebSocket/Delegates/ClientBinaryMessageReceivedEventHandler.cs rename to src/Jung.SimpleWebSocket/Delegates/ClientBinaryMessageReceivedEventHandler.cs diff --git a/Jung.SimpleWebSocket/Delegates/ClientConnectedEventHandler.cs b/src/Jung.SimpleWebSocket/Delegates/ClientConnectedEventHandler.cs similarity index 100% rename from Jung.SimpleWebSocket/Delegates/ClientConnectedEventHandler.cs rename to src/Jung.SimpleWebSocket/Delegates/ClientConnectedEventHandler.cs diff --git a/Jung.SimpleWebSocket/Delegates/ClientDisconnectedEventHandler.cs b/src/Jung.SimpleWebSocket/Delegates/ClientDisconnectedEventHandler.cs similarity index 100% rename from Jung.SimpleWebSocket/Delegates/ClientDisconnectedEventHandler.cs rename to src/Jung.SimpleWebSocket/Delegates/ClientDisconnectedEventHandler.cs diff --git a/Jung.SimpleWebSocket/Delegates/ClientMessageReceivedEventHandler.cs b/src/Jung.SimpleWebSocket/Delegates/ClientMessageReceivedEventHandler.cs similarity index 100% rename from Jung.SimpleWebSocket/Delegates/ClientMessageReceivedEventHandler.cs rename to src/Jung.SimpleWebSocket/Delegates/ClientMessageReceivedEventHandler.cs diff --git a/Jung.SimpleWebSocket/Delegates/DisconnectedEventHandler.cs b/src/Jung.SimpleWebSocket/Delegates/DisconnectedEventHandler.cs similarity index 100% rename from Jung.SimpleWebSocket/Delegates/DisconnectedEventHandler.cs rename to src/Jung.SimpleWebSocket/Delegates/DisconnectedEventHandler.cs diff --git a/Jung.SimpleWebSocket/Delegates/MessageReceivedEventHandler.cs b/src/Jung.SimpleWebSocket/Delegates/MessageReceivedEventHandler.cs similarity index 100% rename from Jung.SimpleWebSocket/Delegates/MessageReceivedEventHandler.cs rename to src/Jung.SimpleWebSocket/Delegates/MessageReceivedEventHandler.cs diff --git a/src/Jung.SimpleWebSocket/Exceptions/ClientIdAlreadyExistsException.cs b/src/Jung.SimpleWebSocket/Exceptions/ClientIdAlreadyExistsException.cs new file mode 100644 index 0000000..91635d6 --- /dev/null +++ b/src/Jung.SimpleWebSocket/Exceptions/ClientIdAlreadyExistsException.cs @@ -0,0 +1,13 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +namespace Jung.SimpleWebSocket.Exceptions +{ + /// + /// Exception thrown when a client with the same id already exists in the client list. + /// + /// The message to display when the exception is thrown. + public class ClientIdAlreadyExistsException(string message) : Exception(message) + { + } +} diff --git a/src/Jung.SimpleWebSocket/Exceptions/ClientNotFoundException.cs b/src/Jung.SimpleWebSocket/Exceptions/ClientNotFoundException.cs new file mode 100644 index 0000000..58d4a95 --- /dev/null +++ b/src/Jung.SimpleWebSocket/Exceptions/ClientNotFoundException.cs @@ -0,0 +1,13 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +namespace Jung.SimpleWebSocket.Exceptions +{ + /// + /// Exception thrown when a client with the given id is not found in the client list. + /// + /// The message to display when the exception is thrown. + public class ClientNotFoundException(string message) : Exception(message) + { + } +} diff --git a/Jung.SimpleWebSocket/Exceptions/SimpleWebSocketException.cs b/src/Jung.SimpleWebSocket/Exceptions/SimpleWebSocketException.cs similarity index 100% rename from Jung.SimpleWebSocket/Exceptions/SimpleWebSocketException.cs rename to src/Jung.SimpleWebSocket/Exceptions/SimpleWebSocketException.cs diff --git a/src/Jung.SimpleWebSocket/Exceptions/UserNotHandledException.cs b/src/Jung.SimpleWebSocket/Exceptions/UserNotHandledException.cs new file mode 100644 index 0000000..148c6f7 --- /dev/null +++ b/src/Jung.SimpleWebSocket/Exceptions/UserNotHandledException.cs @@ -0,0 +1,13 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +using Jung.SimpleWebSocket.Models; + +namespace Jung.SimpleWebSocket.Exceptions +{ + [Serializable] + internal class UserNotHandledException(WebContext responseContext) : Exception + { + public WebContext ResponseContext { get; set; } = responseContext; + } +} \ No newline at end of file diff --git a/Jung.SimpleWebSocket/Exceptions/WebContextException.cs b/src/Jung.SimpleWebSocket/Exceptions/WebContextException.cs similarity index 100% rename from Jung.SimpleWebSocket/Exceptions/WebContextException.cs rename to src/Jung.SimpleWebSocket/Exceptions/WebContextException.cs diff --git a/Jung.SimpleWebSocket/Exceptions/WebSocketClientException.cs b/src/Jung.SimpleWebSocket/Exceptions/WebSocketClientException.cs similarity index 100% rename from Jung.SimpleWebSocket/Exceptions/WebSocketClientException.cs rename to src/Jung.SimpleWebSocket/Exceptions/WebSocketClientException.cs diff --git a/src/Jung.SimpleWebSocket/Exceptions/WebSocketConnectionException.cs b/src/Jung.SimpleWebSocket/Exceptions/WebSocketConnectionException.cs new file mode 100644 index 0000000..c93e501 --- /dev/null +++ b/src/Jung.SimpleWebSocket/Exceptions/WebSocketConnectionException.cs @@ -0,0 +1,31 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +namespace Jung.SimpleWebSocket.Exceptions +{ + /// + /// Represents an exception that occurs during a WebSocket connection attempt. + /// + public class WebSocketConnectionException : Exception + { + /// + /// Initializes a new instance of the class. + /// + public WebSocketConnectionException() { } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public WebSocketConnectionException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with a specified error + /// message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or if no inner exception is + /// specified. + public WebSocketConnectionException(string message, Exception innerException) : base(message, innerException) { } + } +} diff --git a/Jung.SimpleWebSocket/Exceptions/WebSocketServerException.cs b/src/Jung.SimpleWebSocket/Exceptions/WebSocketServerException.cs similarity index 100% rename from Jung.SimpleWebSocket/Exceptions/WebSocketServerException.cs rename to src/Jung.SimpleWebSocket/Exceptions/WebSocketServerException.cs diff --git a/Jung.SimpleWebSocket/Exceptions/WebSocketUpgradeException.cs b/src/Jung.SimpleWebSocket/Exceptions/WebSocketUpgradeException.cs similarity index 100% rename from Jung.SimpleWebSocket/Exceptions/WebSocketUpgradeException.cs rename to src/Jung.SimpleWebSocket/Exceptions/WebSocketUpgradeException.cs diff --git a/src/Jung.SimpleWebSocket/Flows/ClientHandlingFlow.cs b/src/Jung.SimpleWebSocket/Flows/ClientHandlingFlow.cs new file mode 100644 index 0000000..fa1288d --- /dev/null +++ b/src/Jung.SimpleWebSocket/Flows/ClientHandlingFlow.cs @@ -0,0 +1,162 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +using Jung.SimpleWebSocket.Delegates; +using Jung.SimpleWebSocket.Models; +using Jung.SimpleWebSocket.Models.EventArguments; +using Jung.SimpleWebSocket.Utility; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; + +namespace Jung.SimpleWebSocket.Flows +{ + /// + /// A flow that handles the client connection. + /// + /// + /// Creates a new instance of the class. + /// + /// The client to handle. + /// The server that handles the client. + /// The cancellation token of the server. + internal class ClientHandlingFlow(SimpleWebSocketServer server, WebSocketServerClient client, CancellationToken cancellationToken) + { + /// + /// Gets the client associated with the flow. + /// + internal WebSocketServerClient Client { get; set; } = client; + + /// + /// Gets the request context of the client. + /// + internal WebContext? Request { get; set; } = null!; + + /// + /// Gets the upgrade handler for the client. + /// + private WebSocketUpgradeHandler? _upgradeHandler = null; + + /// + /// Gets the response context that is being use to response to the client. + /// + private WebContext? _responseContext = null; + + /// + /// Gets the active clients of the server. + /// + private readonly ConcurrentDictionary _activeClients = server.ActiveClients; + + /// + /// Gets the logger of the server. + /// + private readonly ILogger? _logger = server.Logger; + + /// + /// Gets the cancellation token of the server. + /// + private readonly CancellationToken _cancellationToken = cancellationToken; + + /// + /// Loads the request context. + /// + internal async Task LoadRequestContext() + { + var stream = Client.ClientConnection!.GetStream(); + _upgradeHandler = new WebSocketUpgradeHandler(stream); + Request = await _upgradeHandler.AwaitContextAsync(_cancellationToken).ConfigureAwait(false); + } + + /// + /// Accepts the web socket connection. + /// + internal async Task AcceptWebSocketAsync() + { + // Check if the response context are initialized + ThrowForResponseContextNotInitialized(_responseContext); + + // The client is accepted + await _upgradeHandler!.AcceptWebSocketAsync(Request!, _responseContext, null, _cancellationToken).ConfigureAwait(false); + + // Use the web socket for the client + Client.UseWebSocket(_upgradeHandler.CreateWebSocket(isServer: true)); + Cleanup(); + } + + /// + /// Rejects the web socket connection. + /// + /// The response context to send to the client. + internal async Task RejectWebSocketAsync(WebContext responseContext) + { + // If the status code is SwitchingProtocols, change it to BadRequest + if (responseContext.StatusCode == System.Net.HttpStatusCode.SwitchingProtocols) + { + // If we would send a SwitchingProtocols status code, the client would expect a WebSocket connection. + // We want to reject the connection, so we send a BadRequest status code. + // We could get here if the user sets the status code to SwitchingProtocols in the upgrade event. + responseContext.StatusCode = System.Net.HttpStatusCode.BadRequest; + } + + // Reject the client + await _upgradeHandler!.RejectWebSocketAsync(responseContext, _cancellationToken).ConfigureAwait(false); + Cleanup(); + } + + /// + /// Handles the disconnected client. + /// + internal void HandleDisconnectedClient() + { + _activeClients.TryRemove(Client.Id, out _); + Client.Dispose(); + + _logger?.LogDebug("Client {clientId} is removed.", Client.Id); + } + + /// + /// Raises the upgrade event. + /// + /// The event handler for the upgrade request. + /// The event arguments of the upgrade request. + internal async Task RaiseUpgradeEventAsync(AsyncEventHandler? clientUpgradeRequestReceivedAsync) + { + var eventArgs = new ClientUpgradeRequestReceivedArgs(Client, Request!, _logger); + await AsyncEventRaiser.RaiseAsync(clientUpgradeRequestReceivedAsync, server, eventArgs, _cancellationToken).ConfigureAwait(false); + _responseContext = eventArgs.ResponseContext; + return eventArgs; + } + + /// + /// Tries to add the client to the active user list. + /// + /// True if the client was added to the active user list. False if the client is already connected. + internal bool TryAddClientToActiveUserList() + { + return _activeClients.TryAdd(Client.Id, Client); + } + + /// + /// Throws an exception if the response context is not initialized. + /// + /// The response context to check. + /// + private static void ThrowForResponseContextNotInitialized([NotNull] WebContext? responseContext) + { + if (responseContext is null) + { + throw new InvalidOperationException("The response context is not initialized."); + } + } + + /// + /// Releases resources that are no longer required. + /// + private void Cleanup() + { + _upgradeHandler = null; + _responseContext = null; + Request = null; + } + } +} diff --git a/Jung.SimpleWebSocket/Helpers/WebSocketHelper.cs b/src/Jung.SimpleWebSocket/Helpers/WebSocketHelper.cs similarity index 100% rename from Jung.SimpleWebSocket/Helpers/WebSocketHelper.cs rename to src/Jung.SimpleWebSocket/Helpers/WebSocketHelper.cs diff --git a/Jung.SimpleWebSocket/Jung.SimpleWebSocket.csproj b/src/Jung.SimpleWebSocket/Jung.SimpleWebSocket.csproj similarity index 100% rename from Jung.SimpleWebSocket/Jung.SimpleWebSocket.csproj rename to src/Jung.SimpleWebSocket/Jung.SimpleWebSocket.csproj diff --git a/Jung.SimpleWebSocket/Models/EventArguments/BinaryMessageReceivedArgs.cs b/src/Jung.SimpleWebSocket/Models/EventArguments/BinaryMessageReceivedArgs.cs similarity index 100% rename from Jung.SimpleWebSocket/Models/EventArguments/BinaryMessageReceivedArgs.cs rename to src/Jung.SimpleWebSocket/Models/EventArguments/BinaryMessageReceivedArgs.cs diff --git a/Jung.SimpleWebSocket/Models/EventArguments/ClientBinaryMessageReceivedArgs.cs b/src/Jung.SimpleWebSocket/Models/EventArguments/ClientBinaryMessageReceivedArgs.cs similarity index 100% rename from Jung.SimpleWebSocket/Models/EventArguments/ClientBinaryMessageReceivedArgs.cs rename to src/Jung.SimpleWebSocket/Models/EventArguments/ClientBinaryMessageReceivedArgs.cs diff --git a/Jung.SimpleWebSocket/Models/EventArguments/ClientConnectedArgs.cs b/src/Jung.SimpleWebSocket/Models/EventArguments/ClientConnectedArgs.cs similarity index 100% rename from Jung.SimpleWebSocket/Models/EventArguments/ClientConnectedArgs.cs rename to src/Jung.SimpleWebSocket/Models/EventArguments/ClientConnectedArgs.cs diff --git a/src/Jung.SimpleWebSocket/Models/EventArguments/ClientDisconnectedArgs.cs b/src/Jung.SimpleWebSocket/Models/EventArguments/ClientDisconnectedArgs.cs new file mode 100644 index 0000000..eda1d8e --- /dev/null +++ b/src/Jung.SimpleWebSocket/Models/EventArguments/ClientDisconnectedArgs.cs @@ -0,0 +1,15 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +namespace Jung.SimpleWebSocket.Models.EventArguments; + +/// +/// Represents the arguments of the event when a client disconnects from the server. +/// +/// The reason for the connection closure. if the remote party closed the WebSocket connection without completing the close handshake. +/// The Client that disconnected from the server. +public record ClientDisconnectedArgs( + string? ClosingStatusDescription, + // We use the client object here instead of just the client ID to give more context about the disconnected client. + // When the event is fired, the client is already removed from the active clients list, so we can't access it there. + WebSocketServerClient Client); diff --git a/Jung.SimpleWebSocket/Models/EventArguments/ClientMessageReceivedArgs.cs b/src/Jung.SimpleWebSocket/Models/EventArguments/ClientMessageReceivedArgs.cs similarity index 100% rename from Jung.SimpleWebSocket/Models/EventArguments/ClientMessageReceivedArgs.cs rename to src/Jung.SimpleWebSocket/Models/EventArguments/ClientMessageReceivedArgs.cs diff --git a/src/Jung.SimpleWebSocket/Models/EventArguments/ClientUpgradeRequestReceivedArgs.cs b/src/Jung.SimpleWebSocket/Models/EventArguments/ClientUpgradeRequestReceivedArgs.cs new file mode 100644 index 0000000..e7b3150 --- /dev/null +++ b/src/Jung.SimpleWebSocket/Models/EventArguments/ClientUpgradeRequestReceivedArgs.cs @@ -0,0 +1,27 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Jung.SimpleWebSocket.Models.EventArguments; + +/// +/// Represents the arguments of the event when a upgrade request is received from a client. +/// +/// The client that is sending the upgrade request. +/// The context of the request. +/// The current Logger. +public record ClientUpgradeRequestReceivedArgs(WebSocketServerClient Client, WebContext WebContext, ILogger? Logger) +{ + private WebContext? _responseContext; + + /// + /// Gets or sets a value indicating whether the upgrade request should be Accepted. Default is true. + /// + public bool AcceptRequest { get; set; } = true; + + /// + /// The context that is being use to response to the client. + /// + public WebContext ResponseContext { get => _responseContext ??= new WebContext(); } +} diff --git a/Jung.SimpleWebSocket/Models/EventArguments/DisconnectedArgs.cs b/src/Jung.SimpleWebSocket/Models/EventArguments/DisconnectedArgs.cs similarity index 100% rename from Jung.SimpleWebSocket/Models/EventArguments/DisconnectedArgs.cs rename to src/Jung.SimpleWebSocket/Models/EventArguments/DisconnectedArgs.cs diff --git a/Jung.SimpleWebSocket/Models/EventArguments/MessageReceivedArgs.cs b/src/Jung.SimpleWebSocket/Models/EventArguments/MessageReceivedArgs.cs similarity index 100% rename from Jung.SimpleWebSocket/Models/EventArguments/MessageReceivedArgs.cs rename to src/Jung.SimpleWebSocket/Models/EventArguments/MessageReceivedArgs.cs diff --git a/src/Jung.SimpleWebSocket/Models/EventArguments/SendingUpdateRequestArgs.cs b/src/Jung.SimpleWebSocket/Models/EventArguments/SendingUpdateRequestArgs.cs new file mode 100644 index 0000000..4d59437 --- /dev/null +++ b/src/Jung.SimpleWebSocket/Models/EventArguments/SendingUpdateRequestArgs.cs @@ -0,0 +1,13 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Jung.SimpleWebSocket.Models.EventArguments; + +/// +/// Represents the arguments of the event when a upgrade request is sent to a server. +/// +/// The context of the request. +/// The current Logger. +public record SendingUpgradeRequestArgs(WebContext WebContext, ILogger? Logger); diff --git a/src/Jung.SimpleWebSocket/Models/SimpleWebSocketServerOptions.cs b/src/Jung.SimpleWebSocket/Models/SimpleWebSocketServerOptions.cs new file mode 100644 index 0000000..e832858 --- /dev/null +++ b/src/Jung.SimpleWebSocket/Models/SimpleWebSocketServerOptions.cs @@ -0,0 +1,28 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +using System.Net; + +namespace Jung.SimpleWebSocket.Models +{ + /// + /// Represents the options for the SimpleWebSocketServer. + /// + public class SimpleWebSocketServerOptions + { + /// + /// Gets or sets the local IP address of the server. + /// + public IPAddress LocalIpAddress { get; set; } = IPAddress.Any; + + /// + /// Gets or sets the port of the server. + /// + public int Port { get; set; } + + /// + /// Gets or sets the log level of the server. + /// + public string LogLevel { get; set; } = "Information"; + } +} diff --git a/Jung.SimpleWebSocket/Models/WebContext.cs b/src/Jung.SimpleWebSocket/Models/WebContext.cs similarity index 72% rename from Jung.SimpleWebSocket/Models/WebContext.cs rename to src/Jung.SimpleWebSocket/Models/WebContext.cs index c2603f6..589ab61 100644 --- a/Jung.SimpleWebSocket/Models/WebContext.cs +++ b/src/Jung.SimpleWebSocket/Models/WebContext.cs @@ -3,7 +3,8 @@ using Jung.SimpleWebSocket.Exceptions; using System.Collections.Specialized; -using System.Net.Http.Headers; +using System.Net; +using System.Text.RegularExpressions; namespace Jung.SimpleWebSocket.Models; @@ -15,7 +16,7 @@ namespace Jung.SimpleWebSocket.Models; /// Initializes a new instance of the class. /// /// The content of the web request. -internal class WebContext(string? content = null) +public partial class WebContext(string? content = null) { /// @@ -44,7 +45,23 @@ internal class WebContext(string? content = null) private string? _requestPath = null; /// - /// Gets the headers of the web request. + /// The status code of the context. + /// + private HttpStatusCode? _statusCode; + + + // A Regular Expression to split a string by uppercase letters. + [GeneratedRegex(@"(? + /// The body content. + /// + private string? _bodyContent = null; + + /// + /// Gets the headers. /// public NameValueCollection Headers { @@ -55,6 +72,30 @@ public NameValueCollection Headers } } + /// + /// Gets or Sets the body content. + /// + public string BodyContent + { + get + { + if (_bodyContent == null) + { + var parts = _content.Split("\r\n\r\n", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + _bodyContent = parts.Length > 1 ? parts[1] : string.Empty; + } + return _bodyContent; + } + set + { + if (BodyContent != string.Empty) + { + throw new WebSocketUpgradeException("Body content is already set"); + } + _bodyContent = value; + } + } + /// /// Gets the host name of the web request. /// @@ -140,6 +181,33 @@ private set } } + /// + /// Gets or sets the status code of the context + /// + public HttpStatusCode StatusCode + { + get + { + if (_statusCode == null) + { + var parts = StatusLine.Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2) + { + _statusCode = (HttpStatusCode)Enum.Parse(typeof(HttpStatusCode), parts[1]); + } + else + { + throw new WebSocketUpgradeException("Status code is missing"); + } + } + return _statusCode.Value; + } + set + { + _statusCode = value; + } + } + /// /// Parses the headers of the web request. /// @@ -173,24 +241,26 @@ private NameValueCollection ParseHeaders() /// The created web request context. internal static WebContext CreateRequest(string hostName, int port, string requestPath) { - return new WebContext() + var context = new WebContext() { HostName = hostName, Port = port, - RequestPath = requestPath + RequestPath = requestPath, }; + + return context; } /// /// Checks if the web request contains a specific header with a specific value. /// /// The name of the header. - /// The value of the header. + /// The value of the header. If null, only the header name is checked. /// true if the web request contains the specified header with the specified value; otherwise, false. - internal bool ContainsHeader(string name, string value) + internal bool ContainsHeader(string name, string? value = null) { string? headerValue = Headers[name]; - return headerValue != null && headerValue.Contains(value, StringComparison.OrdinalIgnoreCase); + return headerValue != null && (value == null || headerValue.Contains(value, StringComparison.OrdinalIgnoreCase)); } /// @@ -255,7 +325,6 @@ internal IEnumerable GetAllHeaderValues(string headerName) } } - /// /// Gets the concatenated headers of the web request. /// @@ -300,6 +369,28 @@ public string RequestLine } } + /// + /// Gets a value indicating whether the Content is empty. + /// + public bool IsEmpty => string.IsNullOrWhiteSpace(_content); + + /// + /// Gets the status description of the web request. + /// + public string StatusDescription => GetStatusDescription(StatusCode); + + + /// + /// Gets the status description for the given status code. + /// + /// The status code. + /// A string containing the status description. + public static string GetStatusDescription(HttpStatusCode statusCode) + { + var enumName = Enum.GetName(statusCode) ?? throw new WebSocketUpgradeException("Status code is not a valid HttpStatusCode"); + return string.Join(" ", _splitByUppercaseRegex.Split(enumName)); + } + /// /// Gets the content lines of the web request. /// diff --git a/Jung.SimpleWebSocket/Models/WebSocketServerClient.cs b/src/Jung.SimpleWebSocket/Models/WebSocketServerClient.cs similarity index 75% rename from Jung.SimpleWebSocket/Models/WebSocketServerClient.cs rename to src/Jung.SimpleWebSocket/Models/WebSocketServerClient.cs index d737b17..f48c7c3 100644 --- a/Jung.SimpleWebSocket/Models/WebSocketServerClient.cs +++ b/src/Jung.SimpleWebSocket/Models/WebSocketServerClient.cs @@ -16,10 +16,18 @@ public class WebSocketServerClient : IDisposable /// public string Id { get; private set; } = Guid.NewGuid().ToString(); + /// + /// Gets the properties of the WebSocket client. + /// + /// + /// The properties can be used to store additional information about the client. + /// + public Dictionary Properties { get; } = []; + /// /// Gets the connection of the WebSocket client. /// - internal ITcpClient ClientConnection { get; private set; } + internal ITcpClient? ClientConnection { get; private set; } /// /// Gets the timestamp when the WebSocket client was last seen. @@ -34,7 +42,7 @@ public class WebSocketServerClient : IDisposable /// /// Gets the remote endpoint of the WebSocket client. /// - public EndPoint? RemoteEndPoint => ClientConnection.RemoteEndPoint; + public EndPoint? RemoteEndPoint => ClientConnection?.RemoteEndPoint; /// /// Gets or sets the WebSocket of the client. @@ -46,10 +54,18 @@ public class WebSocketServerClient : IDisposable /// /// The connection of the client. internal WebSocketServerClient(ITcpClient clientConnection) + : base() + { + ClientConnection = clientConnection; + } + + /// + /// Initializes a new instance of the class without a client connection. + /// + internal WebSocketServerClient() { FirstSeen = DateTime.UtcNow; LastConnectionTimestamp = FirstSeen; - ClientConnection = clientConnection; } /// @@ -62,6 +78,16 @@ internal void UpdateClient(ITcpClient client) ClientConnection = client; } + /// + /// Updates the client with a new WebSocket. + /// + /// The web socket that the client should use. + internal void UseWebSocket(IWebSocket? webSocket) + { + ArgumentNullException.ThrowIfNull(webSocket); + WebSocket = webSocket; + } + /// /// Updates the WebSocket client with a new identifier. /// @@ -82,17 +108,13 @@ internal void UpdateId(string id) Id = id; } - internal void UpdateWebSocket(IWebSocket? webSocket) - { - ArgumentNullException.ThrowIfNull(webSocket); - WebSocket = webSocket; - } - /// public void Dispose() { WebSocket?.Dispose(); + WebSocket = null; ClientConnection?.Dispose(); + ClientConnection = null; GC.SuppressFinalize(this); } } diff --git a/Jung.SimpleWebSocket/SimpleWebSocketClient.cs b/src/Jung.SimpleWebSocket/SimpleWebSocketClient.cs similarity index 59% rename from Jung.SimpleWebSocket/SimpleWebSocketClient.cs rename to src/Jung.SimpleWebSocket/SimpleWebSocketClient.cs index b690ab7..f77671e 100644 --- a/Jung.SimpleWebSocket/SimpleWebSocketClient.cs +++ b/src/Jung.SimpleWebSocket/SimpleWebSocketClient.cs @@ -6,8 +6,10 @@ using Jung.SimpleWebSocket.Exceptions; using Jung.SimpleWebSocket.Models; using Jung.SimpleWebSocket.Models.EventArguments; +using Jung.SimpleWebSocket.Utility; using Jung.SimpleWebSocket.Wrappers; using Microsoft.Extensions.Logging; +using System.Net.Sockets; using System.Net.WebSockets; using System.Text; @@ -23,7 +25,7 @@ namespace Jung.SimpleWebSocket /// The port to connect to /// The web socket request path /// A logger to write internal log messages - public class SimpleWebSocketClient(string hostName, int port, string requestPath, ILogger? logger = null) : IWebSocketClient, IDisposable + public class SimpleWebSocketClient(string hostName, int port, string requestPath, ILogger? logger = null) : IWebSocketClient, IDisposable { /// public string HostName { get; } = hostName; @@ -42,6 +44,9 @@ public class SimpleWebSocketClient(string hostName, int port, string requestPath /// public event BinaryMessageReceivedEventHandler? BinaryMessageReceived; + /// + public event AsyncEventHandler? SendingUpgradeRequestAsync; + /// /// The CancellationTokenSource for the client. /// @@ -64,8 +69,21 @@ public class SimpleWebSocketClient(string hostName, int port, string requestPath /// /// A value indicating whether the client is disconnecting. + /// 0=Not disconnecting, 1=Disconnecting + /// + private int _clientIsDisconnecting = 0; + + + /// + /// A value indicating whether the client is disposed. + /// 0=Not Disposed, 1=Disposed /// - private bool _clientIsDisconnecting; + private int _disposed = 0; + + /// + /// Gets a value indicating whether the client is disposed. + /// + private bool Disposed => _disposed == 1; /// /// The logger to write internal log messages. @@ -75,6 +93,8 @@ public class SimpleWebSocketClient(string hostName, int port, string requestPath /// public async Task ConnectAsync(CancellationToken? cancellationToken = null) { + ThrowIfDisposed(); + if (IsConnected) throw new WebSocketClientException(message: "Client is already connected"); cancellationToken ??= CancellationToken.None; @@ -85,8 +105,8 @@ public async Task ConnectAsync(CancellationToken? cancellationToken = null) try { _client = new TcpClientWrapper(); - await _client.ConnectAsync(HostName, Port); - await HandleWebSocketInitiation(_client, linkedTokenSource.Token); + await _client.ConnectAsync(HostName, Port).ConfigureAwait(false); + await HandleWebSocketInitiation(_client, linkedTokenSource.Token).ConfigureAwait(false); _logger?.LogDebug("Connection upgraded, now listening."); _ = ProcessWebSocketMessagesAsync(_webSocket!, linkedTokenSource.Token); @@ -94,7 +114,11 @@ public async Task ConnectAsync(CancellationToken? cancellationToken = null) catch (Exception exception) { _logger?.LogError(exception, "Error connecting to Server"); - if (exception is WebSocketException) + if (exception is SocketException) + { + throw new WebSocketConnectionException(message: "Error connecting to Server", innerException: exception); + } + else if (exception is WebSocketException || exception is WebSocketUpgradeException) { throw; } @@ -108,19 +132,22 @@ public async Task ConnectAsync(CancellationToken? cancellationToken = null) /// public async Task DisconnectAsync(string closingStatusDescription = "Closing", CancellationToken? cancellationToken = null) { - if (_clientIsDisconnecting) throw new WebSocketClientException("Client is already disconnecting"); - _clientIsDisconnecting = true; + // Make sure we only disconnect once + if (Interlocked.Exchange(ref _clientIsDisconnecting, 1) == 1) + { + return; + } cancellationToken ??= CancellationToken.None; var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken.Value, _cancellationTokenSource.Token); - _logger?.LogInformation("Disconnecting from Server"); if (_webSocket != null && (_webSocket.State == WebSocketState.Open || _webSocket.State == WebSocketState.CloseReceived)) { try { - await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, closingStatusDescription, linkedTokenSource.Token); + _logger?.LogInformation("Disconnecting from Server"); + await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, closingStatusDescription, linkedTokenSource.Token).ConfigureAwait(false); } catch (Exception exception) { @@ -135,7 +162,6 @@ public async Task DisconnectAsync(string closingStatusDescription = "Closing", C } } } - _client?.Dispose(); } /// @@ -151,15 +177,32 @@ private async Task HandleWebSocketInitiation(TcpClientWrapper client, Cancellati var socketWrapper = new WebSocketUpgradeHandler(_stream); var requestContext = WebContext.CreateRequest(HostName, Port, RequestPath); - await socketWrapper.SendUpgradeRequestAsync(requestContext, cancellationToken); - var response = await socketWrapper.AwaitContextAsync(cancellationToken); + requestContext = await RaiseUpgradeEventAsync(requestContext, cancellationToken).ConfigureAwait(false); + await socketWrapper.SendUpgradeRequestAsync(requestContext, cancellationToken).ConfigureAwait(false); + var response = await socketWrapper.AwaitContextAsync(cancellationToken).ConfigureAwait(false); WebSocketUpgradeHandler.ValidateUpgradeResponse(response, requestContext); + _webSocket = socketWrapper.CreateWebSocket(isServer: false); } + /// + /// Raises the upgrade event. + /// + /// The request context to use for the upgrade event + /// The cancellation token + /// The event arguments of the upgrade request. + internal async Task RaiseUpgradeEventAsync(WebContext requestContext, CancellationToken cancellationToken) + { + var eventArgs = new SendingUpgradeRequestArgs(requestContext, _logger); + await AsyncEventRaiser.RaiseAsync(SendingUpgradeRequestAsync, this, eventArgs, cancellationToken).ConfigureAwait(false); + return requestContext; + } + /// public async Task SendMessageAsync(string message, CancellationToken? cancellationToken = null) { + ThrowIfDisposed(); + if (!IsConnected) throw new WebSocketClientException(message: "Client is not connected"); if (_webSocket == null) throw new WebSocketClientException(message: "WebSocket is not initialized"); @@ -170,11 +213,12 @@ public async Task SendMessageAsync(string message, CancellationToken? cancellati { // Send the message var buffer = Encoding.UTF8.GetBytes(message); - await _webSocket.SendAsync(new ArraySegment(buffer), WebSocketMessageType.Text, true, linkedTokenSource.Token); + await _webSocket.SendAsync(new ArraySegment(buffer), WebSocketMessageType.Text, true, linkedTokenSource.Token).ConfigureAwait(false); _logger?.LogDebug("Message sent: {message}", message); } catch (Exception exception) { + _logger?.LogError(exception, "Error sending message"); throw new WebSocketClientException(message: "Error sending message", innerException: exception); } } @@ -193,45 +237,69 @@ private async Task ProcessWebSocketMessagesAsync(IWebSocket webSocket, Cancellat throw new InvalidOperationException("WebSocket is not initialized"); } - var buffer = new byte[1024 * 4]; // Buffer for incoming data - while (webSocket.State == WebSocketState.Open) + try { + var buffer = new byte[1024 * 4]; // Buffer for incoming data + while (webSocket.State == WebSocketState.Open) + { - // Read the next message - WebSocketReceiveResult result = await webSocket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + // Read the next message + WebSocketReceiveResult result = await webSocket.ReceiveAsync(new ArraySegment(buffer), cancellationToken).ConfigureAwait(false); - if (result.MessageType == WebSocketMessageType.Text) - { - // Handle the text message - string receivedMessage = Encoding.UTF8.GetString(buffer, 0, result.Count); - _logger?.LogDebug("Message received: {message}", receivedMessage); - _ = Task.Run(() => MessageReceived?.Invoke(this, new MessageReceivedArgs(receivedMessage)), cancellationToken); - } - else if (result.MessageType == WebSocketMessageType.Binary) - { - // Handle the binary message - _logger?.LogDebug("Binary message received, length: {length} bytes", result.Count); - _ = Task.Run(() => BinaryMessageReceived?.Invoke(this, new BinaryMessageReceivedArgs(buffer[..result.Count])), cancellationToken); - } - // We have to check if the client is disconnecting here, - // because then we already sent the close message and we don't want to send another one - else if (result.MessageType == WebSocketMessageType.Close && !_clientIsDisconnecting) - { - _logger?.LogInformation("Received close message from server"); - _ = Task.Run(() => Disconnected?.Invoke(this, new DisconnectedArgs(result.CloseStatusDescription ?? string.Empty)), cancellationToken); - await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None); - break; + if (result.MessageType == WebSocketMessageType.Text) + { + // Handle the text message + string receivedMessage = Encoding.UTF8.GetString(buffer, 0, result.Count); + _logger?.LogDebug("Message received: {message}", receivedMessage); + _ = Task.Run(() => MessageReceived?.Invoke(this, new MessageReceivedArgs(receivedMessage)), cancellationToken); + } + else if (result.MessageType == WebSocketMessageType.Binary) + { + // Handle the binary message + _logger?.LogDebug("Binary message received, length: {length} bytes", result.Count); + _ = Task.Run(() => BinaryMessageReceived?.Invoke(this, new BinaryMessageReceivedArgs(buffer[..result.Count])), cancellationToken); + } + // We have to check if the client is disconnecting here, + // because then we already sent the close message and we don't want to send another one + else if (result.MessageType == WebSocketMessageType.Close && _clientIsDisconnecting == 0) + { + _logger?.LogInformation("Received close message from server"); + _ = Task.Run(() => Disconnected?.Invoke(this, new DisconnectedArgs(result.CloseStatusDescription ?? string.Empty)), cancellationToken); + await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None).ConfigureAwait(false); + break; + } } } + catch (Exception exception) + { + _logger?.LogError(exception, "Error processing WebSocket messages. Connection Closed."); + _ = Task.Run(() => Disconnected?.Invoke(this, new DisconnectedArgs(exception.Message)), cancellationToken); + } } /// public void Dispose() { - _cancellationTokenSource?.Cancel(); - _stream?.Dispose(); - _client?.Dispose(); - GC.SuppressFinalize(this); + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + // Unsubscribe all event handlers + Disconnected = null; + MessageReceived = null; + BinaryMessageReceived = null; + SendingUpgradeRequestAsync = null; + + // Dispose managed resources + _cancellationTokenSource?.Cancel(); + _stream?.Dispose(); + _client?.Dispose(); + + GC.SuppressFinalize(this); + } + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(Disposed, this); } } } \ No newline at end of file diff --git a/src/Jung.SimpleWebSocket/SimpleWebSocketServer.cs b/src/Jung.SimpleWebSocket/SimpleWebSocketServer.cs new file mode 100644 index 0000000..6d02221 --- /dev/null +++ b/src/Jung.SimpleWebSocket/SimpleWebSocketServer.cs @@ -0,0 +1,466 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +using Jung.SimpleWebSocket.Contracts; +using Jung.SimpleWebSocket.Delegates; +using Jung.SimpleWebSocket.Exceptions; +using Jung.SimpleWebSocket.Flows; +using Jung.SimpleWebSocket.Models; +using Jung.SimpleWebSocket.Models.EventArguments; +using Jung.SimpleWebSocket.Utility; +using Jung.SimpleWebSocket.Wrappers; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.WebSockets; +using System.Text; + +namespace Jung.SimpleWebSocket +{ + /// + /// A simple WebSocket server. + /// + /// + /// Initializes a new instance of the class that listens + /// for incoming connection attempts on the specified local IP address and port number. + /// + /// The options for the server + /// A logger to write internal log messages + public class SimpleWebSocketServer(SimpleWebSocketServerOptions options, ILogger? logger = null) : IWebSocketServer, IDisposable + { + /// + public IPAddress LocalIpAddress { get; } = options.LocalIpAddress; + /// + public int Port { get; } = options.Port; + + /// + public event EventHandler? ClientConnected; + /// + public event EventHandler? ClientDisconnected; + /// + public event EventHandler? MessageReceived; + /// + public event EventHandler? BinaryMessageReceived; + + /// + public event AsyncEventHandler? ClientUpgradeRequestReceivedAsync; + + /// + /// A dictionary of active clients. + /// + internal ConcurrentDictionary ActiveClients { get; } = []; + + /// + public string[] ClientIds => [.. ActiveClients.Keys]; + + /// + public int ClientCount => ActiveClients.Count; + + /// + public bool IsListening => _tcpListener?.IsListening ?? false; + + /// + /// A logger to write internal log messages. + /// + internal ILogger? Logger { get; } = logger; + + /// + /// The options for the server. + /// + internal SimpleWebSocketServerOptions Options { get; } = options; + + /// + /// A flag indicating whether the server is started. + /// + public bool IsStarted => _isStarted == 1; + + /// + /// A flag indicating whether the server is shutting down. + /// + private bool IsShuttingDown => _serverShuttingDown == 1; + + /// + /// A flag indicating whether the server is disposed. + /// + private bool Disposed => _disposed == 1; + + /// + /// A flag indicating whether the server is started. + /// 0 = false, 1 = true + /// + private int _isStarted; + + /// + /// A flag indicating whether the server is shutting down. + /// 0 = false, 1 = true + /// + private int _serverShuttingDown; + + /// + /// A flag indicating whether the server is disposed. + /// 0 = false, 1 = true + /// + private int _disposed; + + /// + /// A flag indicating whether the server is disposing. + /// 0 = false, 1 = true + /// + private int _disposing; + + /// + /// A cancellation token source to cancel the server. + /// + private CancellationTokenSource _cancellationTokenSource = new(); + + /// + /// The server that listens for incoming connection attempts. + /// + private ITcpListener? _tcpListener; + + /// + /// Initializes a new instance of the class that listens + /// for incoming connection attempts on the specified local IP address and port number. + /// + /// The options for the server + /// A logger to write internal log messages + public SimpleWebSocketServer(IOptions options, ILogger? logger = null) + : this(options.Value, logger) + { + } + + /// + /// Constructor for dependency injection (used in tests) + /// + /// The options for the server + /// A wrapped tcp listener + /// >A logger to write internal log messages + internal SimpleWebSocketServer(SimpleWebSocketServerOptions options, ITcpListener tcpListener, ILogger? logger = null) + : this(options, logger) + { + _tcpListener = tcpListener; + } + + /// + public void Start(CancellationToken? cancellationToken = null) + { + ThrowIfDisposed(); + + if (Interlocked.Exchange(ref _isStarted, 1) == 1) + { + throw new WebSocketServerException("Server is already started"); + } + + cancellationToken ??= CancellationToken.None; + + _cancellationTokenSource = new CancellationTokenSource(); + var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken.Value, _cancellationTokenSource.Token); + + _tcpListener ??= new TcpListenerWrapper(LocalIpAddress, Port); + _tcpListener.Start(); + _ = Task.Run(async delegate + { + Logger?.LogInformation("Server started at {LocalIpAddress}:{Port}", LocalIpAddress, Port); + while (!linkedTokenSource.IsCancellationRequested) + { + try + { + // Accept the client + var client = await _tcpListener.AcceptTcpClientAsync(linkedTokenSource.Token).ConfigureAwait(false); + + Logger?.LogDebug("Client connected from {endpoint}", client.ClientConnection!.RemoteEndPoint); + + _ = HandleClientAsync(client, linkedTokenSource.Token); + } + catch (OperationCanceledException) + { + // Ignore the exception, because it is thrown when cancellation is requested + } + catch (Exception exception) + { + Logger?.LogError(exception, "Error while accepting Client."); + } + } + }, linkedTokenSource.Token); + } + + /// + public async Task ShutdownServer(CancellationToken? cancellationToken = null) + { + ThrowIfDisposed(); + + if (Interlocked.Exchange(ref _serverShuttingDown, 1) == 1) + { + return; + } + + if (Interlocked.Exchange(ref _isStarted, 0) == 0) + { + Logger?.LogInformation("Server is not started"); + return; + } + + cancellationToken ??= CancellationToken.None; + var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken.Value, _cancellationTokenSource.Token); + + Logger?.LogInformation("Stopping server..."); + + + // copying the active clients to avoid a collection modified exception + var activeClients = ActiveClients.Values.ToArray(); + foreach (var client in activeClients) + { + try + { + if (client.WebSocket != null && client.WebSocket.State == WebSocketState.Open) + { + await client.WebSocket.CloseAsync(WebSocketCloseStatus.EndpointUnavailable, "Server is shutting down", linkedTokenSource.Token).ConfigureAwait(false); + ActiveClients.TryRemove(client.Id, out _); + client?.Dispose(); + } + } + catch + { + // Ignore the exception, because it's not the server's problem if a client does not close the connection + } + } + + + _cancellationTokenSource?.Cancel(); + _tcpListener?.Dispose(); + _tcpListener = null; + ActiveClients.Clear(); + _serverShuttingDown = 0; + Logger?.LogInformation("Server stopped"); + } + + /// + public async Task SendMessageAsync(string clientId, string message, CancellationToken? cancellationToken = null) + { + ThrowIfDisposed(); + + // Find and check the client + if (!ActiveClients.TryGetValue(clientId, out var client)) throw new WebSocketServerException(message: "Client not found"); + if (client.WebSocket == null) throw new WebSocketServerException(message: "Client is not connected"); + + cancellationToken ??= CancellationToken.None; + var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken.Value, _cancellationTokenSource.Token); + + try + { + // Send the message + var buffer = Encoding.UTF8.GetBytes(message); + await client.WebSocket.SendAsync(new ArraySegment(buffer), WebSocketMessageType.Text, true, linkedTokenSource.Token).ConfigureAwait(false); + Logger?.LogDebug("Message sent: {message}.", message); + } + catch (Exception exception) + { + Logger?.LogError(exception, "Error while sending a message."); + throw new WebSocketServerException(message: "An Error occurred sending a message.", innerException: exception); + } + } + + /// + /// + public WebSocketServerClient GetClientById(string clientId) + { + ThrowIfDisposed(); + + if (TryGetClientById(clientId, out var client)) + { + return client; + } + throw new WebSocketServerException(message: "Client not found"); + } + + /// + public bool TryGetClientById(string clientId, [NotNullWhen(true)] out WebSocketServerClient? client) + { + ThrowIfDisposed(); + + if (ActiveClients.TryGetValue(clientId, out client)) + { + if (client != null) + { + return true; + } + } + return false; + } + + /// + public void ChangeClientId(WebSocketServerClient client, string newId) + { + ThrowIfDisposed(); + + // if the client is not found or the new id is already in use, throw an exception + if (!ActiveClients.TryGetValue(client.Id, out var _)) throw new ClientNotFoundException(message: "A client with the given id was not found"); + if (ActiveClients.ContainsKey(newId)) throw new ClientIdAlreadyExistsException(message: "A client with the new id already exists"); + + // because the id is used as a key in the dictionary, + // we have to remove the client and add it again with the new id + ActiveClients.TryRemove(client.Id, out _); + client.UpdateId(newId); + ActiveClients.TryAdd(newId, client); + } + + /// + /// Handles the client connection. + /// + /// The client to handle + /// The cancellation token + /// A asynchronous task + private async Task HandleClientAsync(WebSocketServerClient client, CancellationToken cancellationToken) + { + var flow = new ClientHandlingFlow(this, client, cancellationToken); + try + { + // Load the request context + await flow.LoadRequestContext().ConfigureAwait(false); + + // Raise async client upgrade request received event + var eventArgs = await flow.RaiseUpgradeEventAsync(ClientUpgradeRequestReceivedAsync).ConfigureAwait(false); + + // Respond to the upgrade request + if (eventArgs.AcceptRequest) + { + // Accept the WebSocket connection + await flow.AcceptWebSocketAsync().ConfigureAwait(false); + + if (flow.TryAddClientToActiveUserList()) + { + Logger?.LogDebug("Connection upgraded, now listening on Client {clientId}", flow.Client.Id); + AsyncEventRaiser.RaiseAsyncInNewTask(ClientConnected, this, new ClientConnectedArgs(flow.Client.Id), cancellationToken); + // Start listening for messages + await ProcessWebSocketMessagesAsync(flow.Client, cancellationToken).ConfigureAwait(false); + } + else + { + Logger?.LogDebug("Error while adding Client {clientId} to active clients", flow.Client.Id); + } + } + else + { + // Reject the WebSocket connection + Logger?.LogDebug("Client upgrade request rejected by ClientUpgradeRequestReceivedAsync event."); + await flow.RejectWebSocketAsync(eventArgs.ResponseContext).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Ignore the exception, because it is thrown when cancellation is requested + } + catch (Exception exception) + { + Logger?.LogError(exception, "Error while handling the Client {clientId}", flow.Client.Id); + } + finally + { + // If the client was added and the server is not shutting down, handle the disconnected client + // The client is not added if the connection was rejected + if (!IsShuttingDown) + { + flow.HandleDisconnectedClient(); + } + } + } + + /// + /// Processes the WebSocket messages. + /// + /// The client whose messages to process + /// The cancellation token + /// A asynchronous task + /// + private async Task ProcessWebSocketMessagesAsync(WebSocketServerClient client, CancellationToken cancellationToken) + { + if (client.WebSocket == null) + { + throw new InvalidOperationException("WebSocket is not initialized"); + } + + var webSocket = client.WebSocket; + string? closeStatusDescription = null; + var buffer = new byte[1024 * 4]; // Buffer for incoming data + + try + { + while (webSocket.State == WebSocketState.Open) + { + cancellationToken.ThrowIfCancellationRequested(); + // Read the next message + WebSocketReceiveResult result = await webSocket.ReceiveAsync(new ArraySegment(buffer), cancellationToken).ConfigureAwait(false); + + if (result.MessageType == WebSocketMessageType.Text) + { + // Handle the text message + string receivedMessage = Encoding.UTF8.GetString(buffer, 0, result.Count); + Logger?.LogDebug("Message received: {message}", receivedMessage); + AsyncEventRaiser.RaiseAsyncInNewTask(MessageReceived, this, new ClientMessageReceivedArgs(receivedMessage, client.Id), cancellationToken); + } + else if (result.MessageType == WebSocketMessageType.Binary) + { + // Handle the binary message + Logger?.LogDebug("Binary message received, length: {length} bytes", result.Count); + AsyncEventRaiser.RaiseAsyncInNewTask(BinaryMessageReceived, this, new ClientBinaryMessageReceivedArgs(buffer[..result.Count], client.Id), cancellationToken); + } + // We have to check if the is shutting down here, + // because then we already sent the close message and we don't want to send another one + else if (result.MessageType == WebSocketMessageType.Close && !IsShuttingDown) + { + Logger?.LogInformation("Received close message from Client"); + closeStatusDescription = result.CloseStatusDescription; + await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None).ConfigureAwait(false); + break; + } + } + } + finally + { + // if we leave the loop, the client disconnected + if (!IsShuttingDown) + { + AsyncEventRaiser.RaiseAsyncInNewTask(ClientDisconnected, this, new ClientDisconnectedArgs(closeStatusDescription, client), cancellationToken); + } + } + } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposing, 1) == 1) + { + return; + } + + try + { + // unsubscribe all event handlers + ClientConnected = null; + ClientDisconnected = null; + MessageReceived = null; + BinaryMessageReceived = null; + ClientUpgradeRequestReceivedAsync = null; + + // shutdown server and free resources + ShutdownServer().GetAwaiter().GetResult(); + _cancellationTokenSource?.Cancel(); + _tcpListener?.Dispose(); + _tcpListener = null; + + GC.SuppressFinalize(this); + } + finally + { + Interlocked.Exchange(ref _disposed, 1); + } + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(Disposed, this); + } + } +} \ No newline at end of file diff --git a/src/Jung.SimpleWebSocket/Utility/AsyncEventRaiser.cs b/src/Jung.SimpleWebSocket/Utility/AsyncEventRaiser.cs new file mode 100644 index 0000000..c2a0c91 --- /dev/null +++ b/src/Jung.SimpleWebSocket/Utility/AsyncEventRaiser.cs @@ -0,0 +1,76 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +using Jung.SimpleWebSocket.Delegates; + +namespace Jung.SimpleWebSocket.Utility +{ + /// + /// Helper class to raise an async event. + /// + internal class AsyncEventRaiser + { + /// + /// Helper method to raise an async event. + /// + /// The type of the event arguments. + /// The async event handler + /// The sender of the event. + /// The event arguments. + /// The cancellation token. + /// A task that represents the asynchronous operation. + internal static async Task RaiseAsync(AsyncEventHandler? asyncEvent, object sender, TEventArgs e, CancellationToken cancellationToken) where TEventArgs : class + { + var syncContext = SynchronizationContext.Current; // Capture the current synchronization context + + if (asyncEvent != null) + { + var invocationList = asyncEvent.GetInvocationList(); + + foreach (var handler in invocationList) + { + var asyncHandler = (AsyncEventHandler)handler; + + if (syncContext != null) + { + // Post back to the captured context if it's not null + syncContext.Post(async _ => + { + await asyncHandler(sender, e, cancellationToken).ConfigureAwait(false); + }, null); + } + else + { + // Execute directly if there's no synchronization context + await asyncHandler(sender, e, cancellationToken).ConfigureAwait(false); + } + } + } + } + + /// + /// Helper method to raise an Event in a new Task. + /// + /// The type of the event arguments. + /// The event handler + /// The sender of the event. + /// The event arguments. + /// The cancellation token. + /// A task that represents the asynchronous operation. + internal static void RaiseAsyncInNewTask(EventHandler? @event, object sender, TEventArgs e, CancellationToken cancellationToken) where TEventArgs : class + { + if (@event != null) + { + var invocationList = @event.GetInvocationList(); + Task.Run(() => + { + foreach (var handler in invocationList) + { + var handle = (EventHandler)handler; + handle(sender, e); + } + }, cancellationToken); + } + } + } +} \ No newline at end of file diff --git a/Jung.SimpleWebSocket/Wrappers/NetworkStreamWrapper.cs b/src/Jung.SimpleWebSocket/Wrappers/NetworkStreamWrapper.cs similarity index 96% rename from Jung.SimpleWebSocket/Wrappers/NetworkStreamWrapper.cs rename to src/Jung.SimpleWebSocket/Wrappers/NetworkStreamWrapper.cs index 917a18a..13e5ab1 100644 --- a/Jung.SimpleWebSocket/Wrappers/NetworkStreamWrapper.cs +++ b/src/Jung.SimpleWebSocket/Wrappers/NetworkStreamWrapper.cs @@ -19,7 +19,7 @@ public void Dispose() public async ValueTask ReadAsync(byte[] buffer, CancellationToken cancellationToken) { - return await stream.ReadAsync(buffer, cancellationToken); + return await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); } public ValueTask WriteAsync(byte[] responseBytes, CancellationToken cancellationToken) diff --git a/Jung.SimpleWebSocket/Wrappers/TcpClientWrapper.cs b/src/Jung.SimpleWebSocket/Wrappers/TcpClientWrapper.cs similarity index 100% rename from Jung.SimpleWebSocket/Wrappers/TcpClientWrapper.cs rename to src/Jung.SimpleWebSocket/Wrappers/TcpClientWrapper.cs diff --git a/Jung.SimpleWebSocket/Wrappers/TcpListenerWrapper.cs b/src/Jung.SimpleWebSocket/Wrappers/TcpListenerWrapper.cs similarity index 95% rename from Jung.SimpleWebSocket/Wrappers/TcpListenerWrapper.cs rename to src/Jung.SimpleWebSocket/Wrappers/TcpListenerWrapper.cs index 331d328..a16eb0f 100644 --- a/Jung.SimpleWebSocket/Wrappers/TcpListenerWrapper.cs +++ b/src/Jung.SimpleWebSocket/Wrappers/TcpListenerWrapper.cs @@ -13,7 +13,7 @@ internal class TcpListenerWrapper(IPAddress localIpAddress, int port) : TcpListe public bool IsListening => Active; public new async Task AcceptTcpClientAsync(CancellationToken cancellationToken) { - var tcpClient = await WaitAndWrap(AcceptSocketAsync(cancellationToken)); + var tcpClient = await WaitAndWrap(AcceptSocketAsync(cancellationToken)).ConfigureAwait(false); static async ValueTask WaitAndWrap(ValueTask task) => new TcpClientWrapper(await task.ConfigureAwait(false)); diff --git a/Jung.SimpleWebSocket/Wrappers/WebSocketUpgradeHandler.cs b/src/Jung.SimpleWebSocket/Wrappers/WebSocketUpgradeHandler.cs similarity index 76% rename from Jung.SimpleWebSocket/Wrappers/WebSocketUpgradeHandler.cs rename to src/Jung.SimpleWebSocket/Wrappers/WebSocketUpgradeHandler.cs index 6da2ffc..e7ea051 100644 --- a/Jung.SimpleWebSocket/Wrappers/WebSocketUpgradeHandler.cs +++ b/src/Jung.SimpleWebSocket/Wrappers/WebSocketUpgradeHandler.cs @@ -9,6 +9,7 @@ using Jung.SimpleWebSocket.Exceptions; using Jung.SimpleWebSocket.Helpers; using Jung.SimpleWebSocket.Models; +using System.Net; using System.Net.Sockets; using System.Net.WebSockets; using System.Security.Cryptography; @@ -24,7 +25,7 @@ internal partial class WebSocketUpgradeHandler private string? _acceptedProtocol; private readonly INetworkStream _networkStream; - private readonly WebSocketHelper _websocketHelper; + private readonly WebSocketHelper _webSocketHelper; // Regex for a valid request path: must start with a `/` and can include valid path characters. [GeneratedRegex(@"^\/[a-zA-Z0-9\-._~\/]*$", RegexOptions.Compiled)] @@ -34,13 +35,13 @@ internal partial class WebSocketUpgradeHandler public WebSocketUpgradeHandler(INetworkStream networkStream) { _networkStream = networkStream; - _websocketHelper = new WebSocketHelper(); + _webSocketHelper = new WebSocketHelper(); } - internal WebSocketUpgradeHandler(INetworkStream networkStream, WebSocketHelper websocketHelper) + internal WebSocketUpgradeHandler(INetworkStream networkStream, WebSocketHelper webSocketHelper) { _networkStream = networkStream; - _websocketHelper = websocketHelper; + _webSocketHelper = webSocketHelper; } public async Task AwaitContextAsync(CancellationToken cancellationToken) @@ -52,7 +53,7 @@ public async Task AwaitContextAsync(CancellationToken cancellationTo while (!readingStarted || _networkStream.DataAvailable) { readingStarted = true; - var bytesRead = await _networkStream.ReadAsync(buffer, cancellationToken); + var bytesRead = await _networkStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); sb.Append(Encoding.ASCII.GetString(buffer[..bytesRead])); } @@ -61,16 +62,10 @@ public async Task AwaitContextAsync(CancellationToken cancellationTo return context; } - public async Task AcceptWebSocketAsync(WebContext request, CancellationToken cancellationToken) - { - await AcceptWebSocketAsync(request, null, cancellationToken); - } - - public async Task AcceptWebSocketAsync(WebContext request, string? subProtocol, CancellationToken cancellationToken) + public async Task AcceptWebSocketAsync(WebContext request, WebContext response, string? subProtocol, CancellationToken cancellationToken) { try { - var response = new WebContext(); ValidateWebSocketHeaders(request); var protocol = request.GetConcatenatedHeaders("Sec-WebSocket-Protocol"); if (ProcessWebSocketProtocolHeader(protocol, subProtocol, out var acceptProtocol)) @@ -82,7 +77,8 @@ public async Task AcceptWebSocketAsync(WebContext request, string? subProtocol, response.Headers.Add("Connection", "upgrade"); response.Headers.Add("Upgrade", "websocket"); response.Headers.Add("Sec-WebSocket-Accept", secWebSocketAcceptString); - await SendWebSocketResponseHeaders(response, cancellationToken); + response.StatusCode = HttpStatusCode.SwitchingProtocols; + await SendWebSocketResponseHeaders(response, cancellationToken).ConfigureAwait(false); _acceptedProtocol = subProtocol; } catch (WebSocketUpgradeException) @@ -91,19 +87,31 @@ public async Task AcceptWebSocketAsync(WebContext request, string? subProtocol, } catch (Exception message) { - throw new WebSocketException("Error while accepting the websocket", message); + throw new WebSocketException("Error while accepting the web socket", message); } } private async Task SendWebSocketResponseHeaders(WebContext context, CancellationToken cancellationToken) { var sb = new StringBuilder( - $"HTTP/1.1 101 Switching Protocols\r\n"); + $"HTTP/1.1 {(int)context.StatusCode} {context.StatusDescription}\r\n"); + AddHeaders(context, sb); + CompleteHeaderSection(sb); + + byte[] responseBytes = Encoding.UTF8.GetBytes(sb.ToString()); + await _networkStream.WriteAsync(responseBytes, cancellationToken).ConfigureAwait(false); + } + + private async Task SendWebSocketRejectResponse(WebContext context, CancellationToken cancellationToken) + { + var sb = new StringBuilder( + $"HTTP/1.1 {(int)context.StatusCode} {context.StatusDescription}\r\n"); AddHeaders(context, sb); - FinishMessage(sb); + CompleteHeaderSection(sb); + AddBody(context, sb); byte[] responseBytes = Encoding.UTF8.GetBytes(sb.ToString()); - await _networkStream.WriteAsync(responseBytes, cancellationToken); + await _networkStream.WriteAsync(responseBytes, cancellationToken).ConfigureAwait(false); } private async Task SendWebSocketRequestHeaders(WebContext context, CancellationToken cancellationToken) @@ -112,10 +120,10 @@ private async Task SendWebSocketRequestHeaders(WebContext context, CancellationT $"GET {context.RequestPath} HTTP/1.1\r\n" + $"Host: {context.HostName}:{context.Port}\r\n"); AddHeaders(context, sb); - FinishMessage(sb); + CompleteHeaderSection(sb); byte[] responseBytes = Encoding.UTF8.GetBytes(sb.ToString()); - await _networkStream.WriteAsync(responseBytes, cancellationToken); + await _networkStream.WriteAsync(responseBytes, cancellationToken).ConfigureAwait(false); } private static void AddHeaders(WebContext response, StringBuilder sb) @@ -126,11 +134,16 @@ private static void AddHeaders(WebContext response, StringBuilder sb) } } - private static void FinishMessage(StringBuilder sb) + private static void CompleteHeaderSection(StringBuilder sb) { sb.Append("\r\n"); } + private static void AddBody(WebContext context, StringBuilder sb) + { + sb.Append(context.BodyContent); + } + private static void ValidateWebSocketHeaders(WebContext context) { if (!context.IsWebSocketRequest) @@ -186,7 +199,7 @@ internal static bool ProcessWebSocketProtocolHeader(string? clientSecWebSocketPr return true; } } - throw new WebSocketUpgradeException($"The WebSocket _client requested the following protocols: '{clientSecWebSocketProtocol}', but the server accepted '{subProtocol}' protocol(s)."); + throw new WebSocketUpgradeException($"The WebSocket Client requested the following protocols: '{clientSecWebSocketProtocol}', but the server accepted '{subProtocol}' protocol(s)."); } internal async Task SendUpgradeRequestAsync(WebContext requestContext, CancellationToken token) @@ -200,7 +213,7 @@ internal async Task SendUpgradeRequestAsync(WebContext requestContext, Cancellat requestContext.Headers.Add("Sec-WebSocket-Key", secWebSocketKey); requestContext.Headers.Add("Sec-WebSocket-Version", _supportedVersion); - await SendWebSocketRequestHeaders(requestContext, token); + await SendWebSocketRequestHeaders(requestContext, token).ConfigureAwait(false); } private static void ValidateRequestPath(string requestPath) @@ -219,15 +232,24 @@ private static void ValidateRequestPath(string requestPath) internal static void ValidateUpgradeResponse(WebContext response, WebContext requestContext) { + if (response.IsEmpty) + { + throw new WebSocketUpgradeException("Empty response received"); + } + // Check if the response contains '101 Switching Protocols' if (!response.StatusLine.Contains("101 Switching Protocols")) { + if (!string.IsNullOrEmpty(response.BodyContent)) + { + throw new WebSocketUpgradeException($"Connection not upgraded. The server returned: {response.BodyContent}"); + } throw new WebSocketUpgradeException("Invalid status code, expected '101 Switching Protocols'."); } - // Check for required headers 'Upgrade: websocket' and 'Connection: Upgrade' + // Check for required headers 'Upgrade: websocket' and 'Connection: upgrade' if (!response.ContainsHeader("Upgrade", "websocket") || - !response.ContainsHeader("Connection", "Upgrade")) + !response.ContainsHeader("Connection", "upgrade")) { throw new WebSocketUpgradeException("Invalid 'Upgrade' or 'Connection' header."); } @@ -272,6 +294,27 @@ private static string ComputeWebSocketAccept(string secWebSocketKey) internal IWebSocket CreateWebSocket(bool isServer, TimeSpan? keepAliveInterval = null) { keepAliveInterval ??= TimeSpan.FromSeconds(30); - return _websocketHelper.CreateFromStream(_networkStream.Stream, isServer, _acceptedProtocol, keepAliveInterval.Value); + return _webSocketHelper.CreateFromStream(_networkStream.Stream, isServer, _acceptedProtocol, keepAliveInterval.Value); + } + + internal async Task RejectWebSocketAsync(WebContext response, CancellationToken cancellationToken) + { + // This header is optional, but recommended to inform the client that the connection will be closed + response.Headers.Add("Connection", "close"); + + // If there is body content, ensure Content-Type and Content-Length headers are set + if (!string.IsNullOrEmpty(response.BodyContent)) + { + // Set default Content-Type if not already set + if (response.Headers["Content-Type"] == null) + { + response.Headers.Add("Content-Type", "text/plain"); + } + // Set Content-Length based on the body content length + response.Headers.Add("Content-Length", response.BodyContent.Length.ToString()); + } + + // Send the rejection response + await SendWebSocketRejectResponse(response, cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/Jung.SimpleWebSocket/Wrappers/WebSocketWrapper.cs b/src/Jung.SimpleWebSocket/Wrappers/WebSocketWrapper.cs similarity index 100% rename from Jung.SimpleWebSocket/Wrappers/WebSocketWrapper.cs rename to src/Jung.SimpleWebSocket/Wrappers/WebSocketWrapper.cs diff --git a/Jung.SimpleWebSocket/docs/README.md b/src/Jung.SimpleWebSocket/docs/README.md similarity index 87% rename from Jung.SimpleWebSocket/docs/README.md rename to src/Jung.SimpleWebSocket/docs/README.md index 7d712e8..a0c5979 100644 --- a/Jung.SimpleWebSocket/docs/README.md +++ b/src/Jung.SimpleWebSocket/docs/README.md @@ -1,18 +1,19 @@ # Jung.SimpleWebSocket -Jung.SimpleWebSocket is a lightweight and easy-to-use library for working with WebSocket connections in .NET. +Jung.SimpleWebSocket is library for working with WebSocket connections in .NET. It is built on top of the `System.Net.WebSockets` namespace and provides a simple API for creating WebSocket clients and servers. -By using a TcpListener and TcpClient, Jung.SimpleWebSocket is able to handle WebSocket connections without the need for a full-fledged HTTP server. -You also don't need admin rights to run the server. +The library is designed to give you full access to the WebSocket connection process. +You also don't need admin rights to bind the server to a port, as the library uses the `HttpListener` class to listen for incoming WebSocket connections. ## Installation -You can install Jung.SimpleWebSocket via NuGet package manager or by manually downloading the library. +You can install Jung.SimpleWebSocket via NuGet package manager or by manually downloading and building the library. ### NuGet Package Manager 1. Open the NuGet Package Manager Console in Visual Studio. 2. Run the following command to install the package: `Install-Package Jung.SimpleWebSocket`. + ### Manual Download 1. Go to the [Jung.SimpleWebSocket GitHub repository](https://github.com/cjung95/SimpleWebSocket). diff --git a/tests/Jung.SimpleWebSocket.IntegrationTests/Jung.SimpleWebSocket.IntegrationTests.csproj b/tests/Jung.SimpleWebSocket.IntegrationTests/Jung.SimpleWebSocket.IntegrationTests.csproj new file mode 100644 index 0000000..f1ca8ca --- /dev/null +++ b/tests/Jung.SimpleWebSocket.IntegrationTests/Jung.SimpleWebSocket.IntegrationTests.csproj @@ -0,0 +1,21 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/tests/Jung.SimpleWebSocket.IntegrationTests/ProcedureProvider.cs b/tests/Jung.SimpleWebSocket.IntegrationTests/ProcedureProvider.cs new file mode 100644 index 0000000..ee586bd --- /dev/null +++ b/tests/Jung.SimpleWebSocket.IntegrationTests/ProcedureProvider.cs @@ -0,0 +1,84 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +using Jung.SimpleWebSocket.IntegrationTests.Tests; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace Jung.SimpleWebSocket.IntegrationTests +{ + internal class ProcedureProvider + { + private IOrderedEnumerable _procedures; + + public ProcedureProvider() + { + _procedures = LoadProcedures(); + } + + private IOrderedEnumerable LoadProcedures() + { + var result = new List(); + + var types = Assembly.GetExecutingAssembly().GetTypes().Where(t => t.IsSubclassOf(typeof(BaseTest))); + foreach (var type in types) + { + var testAttribute = (TestInformationAttribute?)Attribute.GetCustomAttribute(type, typeof(TestInformationAttribute)); + if (testAttribute == null) + { + Console.WriteLine($"The test class {type.Name} has no TestInformationAttribute."); + continue; + } + + result.Add(new TestProcedure(testAttribute.Role, testAttribute.Description, type)); + } + return result.OrderBy(x => x.Role); + } + + /// + /// Get the names of the procedures. + /// + /// The names of the procedures. + public string[] GetNames() + { + return [.. _procedures.Select(x => $"{x.Role} - {x.Name}: {x.Description}")]; + } + + /// + /// Get a procedure by its index + /// + /// The index of the procedure + /// + public TestProcedure GetProcedure(int index) + { + if (!HasIndex(index)) + { + throw new IndexOutOfRangeException("There is no procedure at the given index."); + } + + return _procedures.ElementAt(index); + } + + /// + /// Try to get a procedure by name. + /// + /// The name of the procedure. + /// The procedure. + /// True if the procedure was found, false otherwise. + public bool TryGetProcedure(int index, [NotNullWhen(true)] out TestProcedure? procedure) + { + procedure = null; + if (HasIndex(index)) + { + procedure = GetProcedure(index); + return true; + } + return false; + } + + internal bool HasIndex(int index) + { + return _procedures.Count() > index && index >= 0; + } + } +} \ No newline at end of file diff --git a/tests/Jung.SimpleWebSocket.IntegrationTests/Program.cs b/tests/Jung.SimpleWebSocket.IntegrationTests/Program.cs new file mode 100644 index 0000000..ec0b4a8 --- /dev/null +++ b/tests/Jung.SimpleWebSocket.IntegrationTests/Program.cs @@ -0,0 +1,105 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +using Jung.SimpleWebSocket.IntegrationTests.Tests; +using Jung.SimpleWebSocket.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Serilog; + +namespace Jung.SimpleWebSocket.IntegrationTests +{ + public class Program + { + /// + /// Main entry point for the application. + /// + /// The command line arguments. + public static async Task Main(string[] args) + { + var procedureProvider = new ProcedureProvider(); + + Console.WriteLine("Available tests:\n"); + + string[] procedureNames = procedureProvider.GetNames(); + for (int i = 0; i < procedureNames.Length; i++) + { + Console.WriteLine($"{i + 1}: {procedureNames[i]}"); + } + + int chosenProcedureIndex; + do + { + Console.Write("\nEnter the number of the test you want to run: "); + var userInput = Console.ReadLine(); + if (userInput != null) + { + userInput = userInput.Trim().ToLower(); + if (userInput == "exit") + { + return; + } + + if (!int.TryParse(userInput, out int procedureNumber)) + { + Console.WriteLine("Invalid input. Please enter a number."); + continue; + } + + if (procedureNumber >= 1 && procedureNumber <= procedureNames.Length) + { + chosenProcedureIndex = procedureNumber - 1; + break; + } + } + } while (true); + + var procedure = procedureProvider.GetProcedure(chosenProcedureIndex); + var serviceProvider = CreateServiceProvider(procedure); + var logger = serviceProvider.GetRequiredService>(); + + try + { + if (serviceProvider.GetService(procedure.ProcedureType) is not BaseTest test) + { + logger.LogError("The chosen test procedure {procedureType} could not be loaded.", procedure.ProcedureType.FullName); + } + else + { + logger.LogInformation("Running test: {procedureName} ({procedureDescription})", procedure.Name, procedure.Description); + await test.RunAsync(); + } + } + catch (Exception exception) + { + logger.LogError(exception, "An error occurred while running the procedure."); + } + } + + private static ServiceProvider CreateServiceProvider(TestProcedure procedure) + { + var serviceCollection = new ServiceCollection(); + + Log.Logger = new LoggerConfiguration() + .WriteTo.File($"{procedure.Name}-{DateTime.Now:g}-{Guid.NewGuid():n}.txt", rollingInterval: RollingInterval.Day) + .WriteTo.Console(Serilog.Events.LogEventLevel.Information, outputTemplate: "{Level:u3}: {Message:lj}{NewLine}{Exception}") + .MinimumLevel.Debug() + .CreateLogger(); + + serviceCollection.AddSerilog(); + serviceCollection.AddLogging(); + + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + + serviceCollection.Configure(options => + { + options.LocalIpAddress = System.Net.IPAddress.Any; + options.Port = 8085; + }); + return serviceCollection.BuildServiceProvider(); + } + } +} \ No newline at end of file diff --git a/tests/Jung.SimpleWebSocket.IntegrationTests/TestProcedure.cs b/tests/Jung.SimpleWebSocket.IntegrationTests/TestProcedure.cs new file mode 100644 index 0000000..312a4d8 --- /dev/null +++ b/tests/Jung.SimpleWebSocket.IntegrationTests/TestProcedure.cs @@ -0,0 +1,33 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + + +namespace Jung.SimpleWebSocket.IntegrationTests +{ + internal class TestProcedure + { + public string Role; + public string Description; + public Type ProcedureType; + + public TestProcedure(string role, string description, Type type) + { + Role = role; + Description = description; + ProcedureType = type; + } + + public string Name + { + get + { + var result = ProcedureType.Name; + if (ProcedureType.Name.EndsWith("Test")) + { + result = result[0..^4]; + } + return result; + } + } + } +} \ No newline at end of file diff --git a/tests/Jung.SimpleWebSocket.IntegrationTests/Tests/BaseTest.cs b/tests/Jung.SimpleWebSocket.IntegrationTests/Tests/BaseTest.cs new file mode 100644 index 0000000..bbc3e60 --- /dev/null +++ b/tests/Jung.SimpleWebSocket.IntegrationTests/Tests/BaseTest.cs @@ -0,0 +1,14 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Jung.SimpleWebSocket.IntegrationTests.Tests +{ + internal abstract class BaseTest(ILogger logger) + { + protected readonly ILogger _logger = logger; + + internal abstract Task RunAsync(); + } +} diff --git a/tests/Jung.SimpleWebSocket.IntegrationTests/Tests/DisplayEventsServerTest.cs b/tests/Jung.SimpleWebSocket.IntegrationTests/Tests/DisplayEventsServerTest.cs new file mode 100644 index 0000000..2ad1875 --- /dev/null +++ b/tests/Jung.SimpleWebSocket.IntegrationTests/Tests/DisplayEventsServerTest.cs @@ -0,0 +1,78 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +using Jung.SimpleWebSocket.Models.EventArguments; +using Microsoft.Extensions.Logging; + +namespace Jung.SimpleWebSocket.IntegrationTests.Tests +{ + [TestInformation(Role = "Server", Description = "Display the events of the server.")] + internal class DisplayEventsTest(ILogger logger, SimpleWebSocketServer simpleWebSocketServer) : BaseTest(logger) + { + /// + /// The SimpleWebSocketServer instance. + /// + public SimpleWebSocketServer SimpleWebSocketServer { get; } = simpleWebSocketServer; + + /// + /// Runs the server instance. + /// + internal override async Task RunAsync() + { + InitializeEventHandlers(); + + SimpleWebSocketServer.Start(); + + Console.WriteLine("Press any key to stop the SimpleWebSocketServer..."); + Console.ReadKey(); + + UnsubscribeEventHandlers(); + + await SimpleWebSocketServer.ShutdownServer(); + } + + private void InitializeEventHandlers() + { + SimpleWebSocketServer.ClientConnected += SimpleWebSocketServer_ClientConnected; + SimpleWebSocketServer.ClientDisconnected += SimpleWebSocketServer_ClientDisconnected; + SimpleWebSocketServer.MessageReceived += SimpleWebSocketServer_MessageReceived; + SimpleWebSocketServer.BinaryMessageReceived += SimpleWebSocketServer_BinaryMessageReceived; + SimpleWebSocketServer.ClientUpgradeRequestReceivedAsync += ClientUpgradeRequestReceived; + } + + private void UnsubscribeEventHandlers() + { + SimpleWebSocketServer.ClientConnected -= SimpleWebSocketServer_ClientConnected; + SimpleWebSocketServer.ClientDisconnected -= SimpleWebSocketServer_ClientDisconnected; + SimpleWebSocketServer.MessageReceived -= SimpleWebSocketServer_MessageReceived; + SimpleWebSocketServer.BinaryMessageReceived -= SimpleWebSocketServer_BinaryMessageReceived; + SimpleWebSocketServer.ClientUpgradeRequestReceivedAsync -= ClientUpgradeRequestReceived; + } + + private void SimpleWebSocketServer_ClientConnected(object? sender, ClientConnectedArgs e) + { + _logger.LogInformation("Client connected: {ClientId}", e.ClientId); + } + + private void SimpleWebSocketServer_ClientDisconnected(object? sender, ClientDisconnectedArgs e) + { + _logger.LogInformation("Client disconnected: {ClientId}", e.Client.Id); + } + + private void SimpleWebSocketServer_MessageReceived(object? sender, ClientMessageReceivedArgs e) + { + _logger.LogInformation("Message received from {ClientId}: {Message}", e.ClientId, e.Message); + } + + private void SimpleWebSocketServer_BinaryMessageReceived(object? sender, ClientBinaryMessageReceivedArgs e) + { + _logger.LogInformation("Binary message received from {ClientId}: {messages}", e.ClientId, string.Join(' ', e.Message)); + } + + private Task ClientUpgradeRequestReceived(object sender, ClientUpgradeRequestReceivedArgs e, CancellationToken cancellationToken) + { + _logger.LogInformation("Upgrade request received from {ClientId}.", e.Client.Id); + return Task.CompletedTask; + } + } +} diff --git a/tests/Jung.SimpleWebSocket.IntegrationTests/Tests/SendMessagesLoopTest.cs b/tests/Jung.SimpleWebSocket.IntegrationTests/Tests/SendMessagesLoopTest.cs new file mode 100644 index 0000000..1638852 --- /dev/null +++ b/tests/Jung.SimpleWebSocket.IntegrationTests/Tests/SendMessagesLoopTest.cs @@ -0,0 +1,114 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +using Jung.SimpleWebSocket.Exceptions; +using Jung.SimpleWebSocket.Models.EventArguments; +using Microsoft.Extensions.Logging; + +namespace Jung.SimpleWebSocket.IntegrationTests.Tests +{ + [TestInformation(Role = "Client", Description = "Stability test - Sends messages at random times (between 5s and 20s)")] + internal class SendMessagesLoopTest(ILogger logger, ILogger clientLogger) : BaseTest(logger) + { + internal override async Task RunAsync() + { + var cancellationTokenSource = new CancellationTokenSource(); + var token = cancellationTokenSource.Token; + + using var client = new SimpleWebSocketClient("localhost", 8085, string.Empty, clientLogger); + + InitializeClientEvents(client); + + try + { + await client.ConnectAsync(); + + var task = Task.Run(async () => await SendRandomMessages(client, token)); + + Console.WriteLine("Press any key to disconnect from the server..."); + Console.ReadKey(); + + await client.DisconnectAsync(); + cancellationTokenSource.Cancel(); + await task; + } + catch (WebSocketConnectionException exception) + { + _logger.LogError("Failed to connect to the server: {ExceptionMessage}", exception.Message); + + } + catch (Exception exception) + { + _logger.LogError("An error occurred: {ExceptionMessage}", exception.Message); + return; + } + finally + { + UnsubscribeEvents(client); + } + } + + private async Task SendRandomMessages(SimpleWebSocketClient client, CancellationToken cancellationToken) + { + Random random = new(); + int messageCount = 1; + while (!cancellationToken.IsCancellationRequested) + { + try + { + if (!client.IsConnected) + { + // If the client is not connected, exit the loop. + _logger.LogWarning("Client is not connected. Stopping message sending loop."); + break; + } + + string message = $"Message {messageCount++} sent at {DateTime.Now}"; + await client.SendMessageAsync(message, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Sent: {message}", message); + + int delay = random.Next(5000, 20001); // Random delay between 5s (5000ms) and 20s (20000ms) + await Task.Delay(delay, cancellationToken); + } + catch (Exception exception) + { + if (exception is not OperationCanceledException) + { + _logger.LogError(exception, "Error while sending the message."); + } + break; + } + } + } + + private void InitializeClientEvents(SimpleWebSocketClient client) + { + client.Disconnected += Client_Disconnected; + client.MessageReceived += Client_MessageReceived; + client.BinaryMessageReceived += Client_BinaryMessageReceived; + } + + private void UnsubscribeEvents(SimpleWebSocketClient client) + { + client.Disconnected -= Client_Disconnected; + client.MessageReceived -= Client_MessageReceived; + client.BinaryMessageReceived -= Client_BinaryMessageReceived; + } + + + private void Client_BinaryMessageReceived(object sender, BinaryMessageReceivedArgs e) + { + _logger.LogInformation("Binary message received: {binaryMessage}", BitConverter.ToString(e.Message)); + } + + private void Client_MessageReceived(object sender, MessageReceivedArgs e) + { + _logger.LogInformation("Message received: {message}", e.Message); + } + + private void Client_Disconnected(object sender, DisconnectedArgs e) + { + _logger.LogInformation("Disconnected"); + } + } +} diff --git a/tests/Jung.SimpleWebSocket.IntegrationTests/Tests/TestInformationAttribute.cs b/tests/Jung.SimpleWebSocket.IntegrationTests/Tests/TestInformationAttribute.cs new file mode 100644 index 0000000..e386c96 --- /dev/null +++ b/tests/Jung.SimpleWebSocket.IntegrationTests/Tests/TestInformationAttribute.cs @@ -0,0 +1,12 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + + +namespace Jung.SimpleWebSocket.IntegrationTests.Tests +{ + internal class TestInformationAttribute : Attribute + { + public string Role { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Jung.SimpleWebSocketTest/Jung.SimpleWebSocketTest.csproj b/tests/Jung.SimpleWebSocket.UnitTests/Jung.SimpleWebSocket.UnitTests.csproj similarity index 88% rename from Jung.SimpleWebSocketTest/Jung.SimpleWebSocketTest.csproj rename to tests/Jung.SimpleWebSocket.UnitTests/Jung.SimpleWebSocket.UnitTests.csproj index 90bd5a1..cfa0430 100644 --- a/Jung.SimpleWebSocketTest/Jung.SimpleWebSocketTest.csproj +++ b/tests/Jung.SimpleWebSocket.UnitTests/Jung.SimpleWebSocket.UnitTests.csproj @@ -18,7 +18,7 @@ - + diff --git a/tests/Jung.SimpleWebSocket.UnitTests/Mock/ILoggerMockHelper.cs b/tests/Jung.SimpleWebSocket.UnitTests/Mock/ILoggerMockHelper.cs new file mode 100644 index 0000000..b784ee1 --- /dev/null +++ b/tests/Jung.SimpleWebSocket.UnitTests/Mock/ILoggerMockHelper.cs @@ -0,0 +1,42 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +using Microsoft.Extensions.Logging; +using Moq; + +namespace Jung.SimpleWebSocket.UnitTests.Mock +{ + internal class ILoggerMockHelper where T : class + { + internal Mock> LoggerMock { get; } + internal ILogger Logger => LoggerMock.Object; + + public ILoggerMockHelper(string name) + { + LoggerMock = new Mock>(); + ILoggerMockHelper.SetUpLogger(LoggerMock, name); + } + + private static void SetUpLogger(Mock> mock, string loggerName) + { + mock.Setup(m => m.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()! + )).Callback(new InvocationAction(invocation => + { + var logLevel = (LogLevel)invocation.Arguments[0]; + var eventId = (EventId)invocation.Arguments[1]; + var state = invocation.Arguments[2]; + var exception = (Exception)invocation.Arguments[3]; + var formatter = invocation.Arguments[4]; + + var invokeMethod = formatter.GetType().GetMethod("Invoke"); + var logMessage = invokeMethod!.Invoke(formatter, [state, exception]); + LoggerMessages.AddMessage($"[{DateTime.Now:HH:mm:ss:fff}] {loggerName} ({logLevel}): {logMessage}"); + })); + } + } +} diff --git a/tests/Jung.SimpleWebSocket.UnitTests/Mock/LoggerMessages.cs b/tests/Jung.SimpleWebSocket.UnitTests/Mock/LoggerMessages.cs new file mode 100644 index 0000000..86af14e --- /dev/null +++ b/tests/Jung.SimpleWebSocket.UnitTests/Mock/LoggerMessages.cs @@ -0,0 +1,27 @@ +// This file is part of the Jung SimpleWebSocket project. +// The project is licensed under the MIT license. + +namespace Jung.SimpleWebSocket.UnitTests.Mock +{ + internal static class LoggerMessages + { + private static readonly object _lock = new object(); + internal static List Messages { get; } = []; + + internal static void AddMessage(string message) + { + lock (_lock) + { + Messages.Add(message); + } + } + + internal static string[] GetMessages() + { + lock (_lock) + { + return [.. Messages]; + } + } + } +} diff --git a/Jung.SimpleWebSocketTest/SimpleWebSocketTest.cs b/tests/Jung.SimpleWebSocket.UnitTests/SimpleWebSocketTest.cs similarity index 57% rename from Jung.SimpleWebSocketTest/SimpleWebSocketTest.cs rename to tests/Jung.SimpleWebSocket.UnitTests/SimpleWebSocketTest.cs index 0b058ec..4699925 100644 --- a/Jung.SimpleWebSocketTest/SimpleWebSocketTest.cs +++ b/tests/Jung.SimpleWebSocket.UnitTests/SimpleWebSocketTest.cs @@ -1,25 +1,24 @@ // This file is part of the Jung SimpleWebSocket project. // The project is licensed under the MIT license. -using Jung.SimpleWebSocket; -using Microsoft.Extensions.Logging; -using Moq; +using Jung.SimpleWebSocket.Exceptions; +using Jung.SimpleWebSocket.Models; +using Jung.SimpleWebSocket.UnitTests.Mock; using NUnit.Framework; using System.Diagnostics; using System.Net; using System.Runtime.CompilerServices; -// internals of the simple web socket are visible to the test project +// internals of the simple web socket project are visible to the test project // because of the InternalsVisibleTo attribute in the AssemblyInfo.cs -namespace Jung.SimpleWebSocketTest +namespace Jung.SimpleWebSocket.UnitTests { [TestFixture] public class SimpleWebSocketTest { - private List _logMessages = []; - private Mock> _serverLogger; - private Mock> _clientLogger; + private ILoggerMockHelper _serverLoggerMockHelper; + private ILoggerMockHelper _clientLoggerMockHelper; [OneTimeSetUp] public void SetUpOnce() @@ -30,11 +29,8 @@ public void SetUpOnce() [SetUp] public void SetUp() { - _logMessages.Clear(); - _serverLogger = new Mock>(); - _clientLogger = new Mock>(); - SetUpLogger(_serverLogger, "Server"); - SetUpLogger(_clientLogger, "Client"); + _serverLoggerMockHelper = new("Server"); + _clientLoggerMockHelper = new("Client"); } [OneTimeTearDown] @@ -43,26 +39,79 @@ public void EndTest() Trace.Flush(); } - private void SetUpLogger(Mock> mock, string loggerName) + [Test] + public void ChangeClientId_UserIdUnique_ShouldUpdateId() + { + // Arrange + var serverOptions = new SimpleWebSocketServerOptions + { + LocalIpAddress = IPAddress.Any, + Port = 8010, + }; + + var connectedClient1 = new WebSocketServerClient(); + var connectedClient2 = new WebSocketServerClient(); + var oldId = connectedClient1.Id; + + using var server = new SimpleWebSocketServer(serverOptions, _serverLoggerMockHelper.Logger); + if (!server.ActiveClients.TryAdd(connectedClient1.Id, connectedClient1) || + !server.ActiveClients.TryAdd(connectedClient2.Id, connectedClient2)) + { + throw new Exception("Could not add clients to the server."); + } + + // Act + var newId = Guid.NewGuid().ToString(); + server.ChangeClientId(connectedClient1, newId); + + // Assert + Assert.Multiple(() => + { + Assert.That(connectedClient1.Id, Is.EqualTo(newId)); + Assert.That(server.ActiveClients.ContainsKey(oldId), Is.False); + Assert.That(server.ActiveClients.ContainsKey(newId), Is.True); + }); + } + + [Test] + public void ChangeClientId_UserIdDuplicated_ShouldThrowException() + { + // Arrange + var serverOptions = new SimpleWebSocketServerOptions + { + LocalIpAddress = IPAddress.Any, + Port = 8010, + }; + + var connectedClient1 = new WebSocketServerClient(); + var connectedClient2 = new WebSocketServerClient(); + + + using var server = new SimpleWebSocketServer(serverOptions, _serverLoggerMockHelper.Logger); + if (!server.ActiveClients.TryAdd(connectedClient1.Id, connectedClient1) || + !server.ActiveClients.TryAdd(connectedClient2.Id, connectedClient2)) + { + throw new Exception("Could not add clients to the server."); + } + + // Act & Assert + Assert.That(() => server.ChangeClientId(connectedClient1, connectedClient2.Id), Throws.Exception.TypeOf()); + } + + [Test] + public void ChangeClientId_TargetUserNotExisting_ShouldThrowException() { - mock.Setup(m => m.Log( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>()! - )).Callback(new InvocationAction(invocation => + // Arrange + var serverOptions = new SimpleWebSocketServerOptions { - var logLevel = (LogLevel)invocation.Arguments[0]; - var eventId = (EventId)invocation.Arguments[1]; - var state = invocation.Arguments[2]; - var exception = (Exception)invocation.Arguments[3]; - var formatter = invocation.Arguments[4]; - - var invokeMethod = formatter.GetType().GetMethod("Invoke"); - var logMessage = invokeMethod!.Invoke(formatter, new[] { state, exception }); - _logMessages.Add($"{loggerName}({logLevel}): {logMessage}"); - })); + LocalIpAddress = IPAddress.Any, + Port = 8010, + }; + + using var server = new SimpleWebSocketServer(serverOptions, _serverLoggerMockHelper.Logger); + + // Act & Assert + Assert.That(() => server.ChangeClientId(new WebSocketServerClient(), Guid.NewGuid().ToString()), Throws.Exception.TypeOf()); } [Test] @@ -70,8 +119,14 @@ private void SetUpLogger(Mock> mock, string loggerName) public async Task TestClientServerConnection_ShouldSendAndReceiveHelloWorld() { // Arrange - using var server = new SimpleWebSocketServer(IPAddress.Any, 8010, _serverLogger.Object); - using var client = new SimpleWebSocketClient(IPAddress.Loopback.ToString(), 8010, "/", _clientLogger.Object); + var serverOptions = new SimpleWebSocketServerOptions + { + LocalIpAddress = IPAddress.Any, + Port = 8010, + }; + + using var server = new SimpleWebSocketServer(serverOptions, _serverLoggerMockHelper.Logger); + using var client = new SimpleWebSocketClient(IPAddress.Loopback.ToString(), 8010, "/", logger: _clientLoggerMockHelper.Logger); const string Message = "Hello World"; @@ -96,7 +151,7 @@ public async Task TestClientServerConnection_ShouldSendAndReceiveHelloWorld() server.ClientDisconnected += (sender, obj) => { - receivedClosingDescription = obj.ClosingStatusDescription; + receivedClosingDescription = obj.ClosingStatusDescription ?? string.Empty; disconnectResetEvent.Set(); }; @@ -106,20 +161,48 @@ public async Task TestClientServerConnection_ShouldSendAndReceiveHelloWorld() messageResetEvent.Set(); }; + server.ClientUpgradeRequestReceivedAsync += async (sender, args, cancellationToken) => + { + // Get the IP address of the client + var IpAddress = (args.Client.RemoteEndPoint as IPEndPoint)?.Address; + if (IpAddress == null) + { + args.AcceptRequest = false; + return; + } + + // Check the IpAddress against the database + var isWhitelistedEndPoint = await DbContext_IpAddresses_Contains(IpAddress, cancellationToken); + if (!isWhitelistedEndPoint) + { + args.ResponseContext.StatusCode = HttpStatusCode.Forbidden; + args.ResponseContext.BodyContent = "Connection only possible via local network."; + args.AcceptRequest = false; + } + args.Client.Properties["test"] = "test"; + }; + // Act server.Start(); await client.ConnectAsync(); - WaitForManualResetEventOrThrow(connectResetEvent); await client.SendMessageAsync(Message); WaitForManualResetEventOrThrow(messageResetEvent); await client.DisconnectAsync(ClosingStatusDescription); - WaitForManualResetEventOrThrow(disconnectResetEvent, 100000); + WaitForManualResetEventOrThrow(disconnectResetEvent); + + // test if the server accepts the client again + var client2 = new SimpleWebSocketClient(IPAddress.Loopback.ToString(), 8010, "/", logger: _clientLoggerMockHelper.Logger); + await client2.ConnectAsync(); + + await Task.Delay(100); + + await client2.SendMessageAsync("Hello World"); await server.ShutdownServer(CancellationToken.None); - _logMessages.ForEach(m => Trace.WriteLine(m)); + Array.ForEach(LoggerMessages.GetMessages(), m => Trace.WriteLine(m)); // Assert Assert.Multiple(() => @@ -129,13 +212,32 @@ public async Task TestClientServerConnection_ShouldSendAndReceiveHelloWorld() }); } + + /// + /// Fake Async method to simulate a database call to check if the IP address is in the database. + /// + /// The IP address to check. + /// The cancellation token. + /// A task that represents the asynchronous operation. The task result contains a value indicating whether the IP address is in the database. + private static async Task DbContext_IpAddresses_Contains(IPAddress ipAddress, CancellationToken cancellationToken) + { + await Task.Delay(100, cancellationToken); + return ipAddress.Equals(IPAddress.Loopback); + } + [Test] [Platform("Windows7,Windows8,Windows8.1,Windows10", Reason = "This test establishes a TCP client-server connection using SimpleWebSocket, which relies on specific networking features and behaviors that are only available and consistent on Windows platforms. Running this test on non-Windows platforms could lead to inconsistent results or failures due to differences in networking stack implementations.")] public async Task TestClientServerConnection_ShouldSendAndReceiveHelloWorld2() { // Arrange - using var server = new SimpleWebSocketServer(IPAddress.Any, 8010, _serverLogger.Object); - using var client = new SimpleWebSocketClient(IPAddress.Loopback.ToString(), 8010, "/", _clientLogger.Object); + var serverOptions = new SimpleWebSocketServerOptions + { + LocalIpAddress = IPAddress.Any, + Port = 8010 + }; + + using var server = new SimpleWebSocketServer(serverOptions, _serverLoggerMockHelper.Logger); + using var client = new SimpleWebSocketClient(IPAddress.Loopback.ToString(), 8010, "/", logger: _clientLoggerMockHelper.Logger); const string Message = "Hello World"; @@ -182,7 +284,7 @@ public async Task TestClientServerConnection_ShouldSendAndReceiveHelloWorld2() await server.ShutdownServer(CancellationToken.None); WaitForManualResetEventOrThrow(disconnectResetEvent, 100); - _logMessages.ForEach(m => Trace.WriteLine(m)); + Array.ForEach(LoggerMessages.GetMessages(), m => Trace.WriteLine(m)); // Assert Assert.Multiple(() => @@ -197,7 +299,13 @@ public async Task TestClientServerConnection_ShouldSendAndReceiveHelloWorld2() public async Task TestMultipleClientServerConnection_ShouldSendAndReceiveHelloWorld() { // Arrange - using var server = new SimpleWebSocketServer(IPAddress.Any, 8010); + var serverOptions = new SimpleWebSocketServerOptions + { + LocalIpAddress = IPAddress.Any, + Port = 8010 + }; + + using var server = new SimpleWebSocketServer(serverOptions); List clients = []; var message = "Hello World"; const int clientsCount = 200; diff --git a/Jung.SimpleWebSocketTest/WebContextTest.cs b/tests/Jung.SimpleWebSocket.UnitTests/WebContextTest.cs similarity index 97% rename from Jung.SimpleWebSocketTest/WebContextTest.cs rename to tests/Jung.SimpleWebSocket.UnitTests/WebContextTest.cs index 7999dd7..cc3ad0e 100644 --- a/Jung.SimpleWebSocketTest/WebContextTest.cs +++ b/tests/Jung.SimpleWebSocket.UnitTests/WebContextTest.cs @@ -5,7 +5,10 @@ using Jung.SimpleWebSocket.Models; using NUnit.Framework; -namespace Jung.SimpleWebSocketTest +// internals of the simple web socket project are visible to the test project +// because of the InternalsVisibleTo attribute in the AssemblyInfo.cs + +namespace Jung.SimpleWebSocket.UnitTests { [TestFixture] internal class WebContextTest diff --git a/Jung.SimpleWebSocketTest/WebSocketUpgradeHandlerTests.cs b/tests/Jung.SimpleWebSocket.UnitTests/WebSocketUpgradeHandlerTests.cs similarity index 95% rename from Jung.SimpleWebSocketTest/WebSocketUpgradeHandlerTests.cs rename to tests/Jung.SimpleWebSocket.UnitTests/WebSocketUpgradeHandlerTests.cs index f8ba27e..503df3e 100644 --- a/Jung.SimpleWebSocketTest/WebSocketUpgradeHandlerTests.cs +++ b/tests/Jung.SimpleWebSocket.UnitTests/WebSocketUpgradeHandlerTests.cs @@ -1,7 +1,6 @@ // This file is part of the Jung SimpleWebSocket project. // The project is licensed under the MIT license. -using Jung.SimpleWebSocket; using Jung.SimpleWebSocket.Contracts; using Jung.SimpleWebSocket.Exceptions; using Jung.SimpleWebSocket.Helpers; @@ -10,7 +9,10 @@ using NUnit.Framework; using System.Text; -namespace Jung.SimpleWebSocketTest +// internals of the simple web socket project are visible to the test project +// because of the InternalsVisibleTo attribute in the AssemblyInfo.cs + +namespace Jung.SimpleWebSocket.UnitTests { public class WebSocketUpgradeHandlerTests { @@ -64,7 +66,7 @@ public async Task AcceptWebSocketAsync_ShouldSendUpgradeResponse(string hostname _mockNetworkStream.Setup(ns => ns.WriteAsync(It.IsAny(), It.IsAny())).Callback((buffer, ct) => { response = Encoding.UTF8.GetString(buffer); }); // Act - await _socketWrapper.AcceptWebSocketAsync(request, cancellationToken); + await _socketWrapper.AcceptWebSocketAsync(request, new WebContext(), null, cancellationToken); // Assert Assert.That(response, Does.Contain("HTTP/1.1 101 Switching Protocols")); @@ -90,7 +92,7 @@ public async Task AcceptWebSocketAsync_ShouldSendUpgradeResponseWithCorrectProto _mockNetworkStream.Setup(ns => ns.WriteAsync(It.IsAny(), It.IsAny())).Callback((buffer, ct) => { response = Encoding.UTF8.GetString(buffer); }); // Act - await _socketWrapper.AcceptWebSocketAsync(request, serverSubprotocol, cancellationToken); + await _socketWrapper.AcceptWebSocketAsync(request, new WebContext(), serverSubprotocol, cancellationToken); // Assert Assert.Multiple(() =>