diff --git a/Jung.SimpleWebSocket.sln b/Jung.SimpleWebSocket.sln index ed5da37..d6ee15b 100644 --- a/Jung.SimpleWebSocket.sln +++ b/Jung.SimpleWebSocket.sln @@ -3,9 +3,9 @@ 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("{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.SimpleWebSocketTest", "Jung.SimpleWebSocketTest\Jung.SimpleWebSocketTest.csproj", "{26725C3C-8E90-49AC-9EE4-2A77ADB2229D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jung.SimpleWebSocket.UnitTests", "tests\Jung.SimpleWebSocket.UnitTests\Jung.SimpleWebSocket.UnitTests.csproj", "{26725C3C-8E90-49AC-9EE4-2A77ADB2229D}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/Jung.SimpleWebSocket/Delegates/PassiveUserExpiredEventHandler.cs b/Jung.SimpleWebSocket/Delegates/PassiveUserExpiredEventHandler.cs deleted file mode 100644 index de46577..0000000 --- a/Jung.SimpleWebSocket/Delegates/PassiveUserExpiredEventHandler.cs +++ /dev/null @@ -1,13 +0,0 @@ -// This file is part of the Jung SimpleWebSocket project. -// The project is licensed under the MIT license. - -using Jung.SimpleWebSocket.Models.EventArguments; - -namespace Jung.SimpleWebSocket.Delegates; - -/// -/// The event handler for the passive user expired event. -/// -/// The sender of the event. -/// The arguments of the event. -public delegate void PassiveUserExpiredEventHandler(object sender, PassiveUserExpiredArgs e); \ No newline at end of file diff --git a/Jung.SimpleWebSocket/Flows/ClientHandlingFlow.cs b/Jung.SimpleWebSocket/Flows/ClientHandlingFlow.cs deleted file mode 100644 index ad5e3ab..0000000 --- a/Jung.SimpleWebSocket/Flows/ClientHandlingFlow.cs +++ /dev/null @@ -1,237 +0,0 @@ -// This file is part of the Jung SimpleWebSocket project. -// 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 Jung.SimpleWebSocket.Utility; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Concurrent; -using System.Net; - -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 a value indicating whether the client was accepted. - /// - private bool _clientAccepted; - - /// - /// Gets a value indicating whether the client was a passive client. - /// - private bool _clientWasPassiveClient; - - /// - /// Gets the options of the server. - /// - private readonly SimpleWebSocketServerOptions _options = server.Options; - - /// - /// Gets the active clients of the server. - /// - private readonly ConcurrentDictionary _activeClients = server.ActiveClients; - - /// - /// Gets the passive clients of the server. - /// - private readonly IDictionary _passiveClients = server.PassiveClients; - - /// - /// Gets the logger of the server. - /// - private readonly ILogger? _logger = server.Logger; - - /// - /// Gets the cancellation token of the server. - /// - private readonly CancellationToken _cancellationToken = cancellationToken; - - /// - /// The lock object for the client dictionaries. - /// - private static readonly object _clientLock = new(); - - /// - /// Loads the request context. - /// - internal async Task LoadRequestContext() - { - var stream = Client.ClientConnection!.GetStream(); - _upgradeHandler = new WebSocketUpgradeHandler(stream); - Request = await _upgradeHandler.AwaitContextAsync(_cancellationToken); - } - - /// - /// Handles the client identification. - /// - internal void HandleClientIdentification() - { - // Check if disconnected clients are remembered and can be reactivated - if (_options.RememberDisconnectedClients) - { - // Check if the request contains a user id - if (Request.ContainsUserId) - { - _logger?.LogDebug("User id found in request: {userId}", Request.UserId); - - lock (_clientLock) - { - ThrowForUserAlreadyConnected(); - - // Check if the client is an existing passive client - var clientExists = _passiveClients.ContainsKey(Request.UserId); - if (clientExists) - { - _logger?.LogDebug("Passive Client found for user id {userId} - reactivating user.", Request.UserId); - - // Use the existing client - // Update the client with the new connection - // Remove the client from the passive clients - var passiveClient = _passiveClients[Request.UserId]; - passiveClient.UpdateClient(Client.ClientConnection!); - Client = passiveClient; - var clientRemoved = _passiveClients.Remove(Request.UserId); - - // Set the flag that the client was a passive client - // This should only be set if the client was removed in this specific flow - // Otherwise its possible that the client is handled twice - _clientWasPassiveClient = clientRemoved; - } - else - { - // Client is not a passive client - // Update the clients user id - Client.UpdateId(Request.UserId); - } - } - } - } - } - - /// - /// Throws an exception if the user is already connected. - /// - /// - private void ThrowForUserAlreadyConnected() - { - // No passive client found, checking for active clients with the same id - if (_activeClients.ContainsKey(Request.UserId)) - { - _logger?.LogDebug("Active Client found for user id {userId} - rejecting connection.", Request.UserId); - // Reject the connection - - var responseContext = new WebContext - { - StatusCode = HttpStatusCode.Conflict, - BodyContent = "User id already in use" - }; - throw new UserNotHandledException(responseContext); - } - } - - /// - /// Accepts the web socket connection. - /// - internal async Task AcceptWebSocketAsync() - { - // The client is accepted - await _upgradeHandler.AcceptWebSocketAsync(Request, _responseContext, Client.Id, null, _cancellationToken); - - // Use the web socket for the client - Client.UseWebSocket(_upgradeHandler.CreateWebSocket(isServer: true)); - - // Set the flag that the client was accepted - // This is used to determine if the client should be added to the passive clients after disconnect - _clientAccepted = true; - } - - /// - /// Rejects the web socket connection. - /// - /// The response context to send to the client. - internal async Task RejectWebSocketAsync(WebContext responseContext) - { - await _upgradeHandler.RejectWebSocketAsync(responseContext, _cancellationToken); - } - - /// - /// Handles the disconnected client. - /// - internal void HandleDisconnectedClient() - { - if (_clientWasPassiveClient || _clientAccepted) - { - lock (_clientLock) - { - _activeClients.TryRemove(Client.Id, out _); - Client.Dispose(); - - if (_options.RememberDisconnectedClients) - { - _logger?.LogDebug("Client {clientId} is now a passive user.", Client.Id); - _passiveClients.Add(Client.Id, Client); - } - else - { - _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); - _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); - } - } -} \ No newline at end of file diff --git a/Jung.SimpleWebSocket/Models/EventArguments/ItemExpiredArgs.cs b/Jung.SimpleWebSocket/Models/EventArguments/ItemExpiredArgs.cs deleted file mode 100644 index f2b78be..0000000 --- a/Jung.SimpleWebSocket/Models/EventArguments/ItemExpiredArgs.cs +++ /dev/null @@ -1,10 +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 an item is expired. -/// -/// The item that is expired. -public record ItemExpiredArgs(TValue Item); \ No newline at end of file diff --git a/Jung.SimpleWebSocket/Models/EventArguments/PassiveUserExpiredEventArgs.cs b/Jung.SimpleWebSocket/Models/EventArguments/PassiveUserExpiredEventArgs.cs deleted file mode 100644 index 19cd99a..0000000 --- a/Jung.SimpleWebSocket/Models/EventArguments/PassiveUserExpiredEventArgs.cs +++ /dev/null @@ -1,10 +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 passive user expired. -/// -/// The identifier of the user that expired. -public record PassiveUserExpiredArgs(string ClientId); \ No newline at end of file diff --git a/Jung.SimpleWebSocket/Models/SimpleWebSocketServerOptions.cs b/Jung.SimpleWebSocket/Models/SimpleWebSocketServerOptions.cs deleted file mode 100644 index 7e5a486..0000000 --- a/Jung.SimpleWebSocket/Models/SimpleWebSocketServerOptions.cs +++ /dev/null @@ -1,44 +0,0 @@ -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; } - - /// - /// Switch for remembering disconnected clients. - /// - /// - /// If true the server will put disconnected clients into a passive client list. - /// This clients can reidentify themselves with their user id. - /// - public bool RememberDisconnectedClients { get; set; } = false; - - /// - /// Switch for removing passive clients after the end of the . - /// - public bool RemovePassiveClientsAfterClientExpirationTime { get; set; } = false; - - /// - /// Switch for sending the user id to the client. - /// - public bool SendUserIdToClient { get; set; } = false; - - /// - /// The time after which a passive client is removed from the passive client list. - /// - public TimeSpan PassiveClientLifetime { get; set; } = TimeSpan.FromMinutes(1); - } -} \ No newline at end of file diff --git a/Jung.SimpleWebSocket/Utility/ExpiringDictionary.cs b/Jung.SimpleWebSocket/Utility/ExpiringDictionary.cs deleted file mode 100644 index 52d3131..0000000 --- a/Jung.SimpleWebSocket/Utility/ExpiringDictionary.cs +++ /dev/null @@ -1,281 +0,0 @@ -// 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; -using System.Collections; -using System.Diagnostics.CodeAnalysis; - -namespace Jung.SimpleWebSocket.Utility; - -/// -/// Creates a dictionary with expiration time for each item. -/// -/// The type of the keys in the dictionary. -/// The type of the values in the dictionary. -/// The expiration time for each item. -/// The logger to log exceptions. -public class ExpiringDictionary(TimeSpan expiration, ILogger? logger = null): IDictionary where TKey : class -{ - - /// - /// Occurs when an item is expired. - /// - public event EventHandler>? ItemExpired; - - private readonly SortedList _expirationQueue = []; - private readonly Dictionary _dictionary = []; - - private bool _cleanupInProgress = false; - - /// - /// Add the specified key and value to the dictionary. - /// - /// - /// - public void Add(TKey key, TValue value) - { - lock (_dictionary) - { - // Add the item to the dictionary - _dictionary[key] = value; - - // Add item with expiration time to the queue - var expirationTime = DateTime.Now.Add(expiration); - - lock (_expirationQueue) - { - _expirationQueue.Add(expirationTime, key); - } - } - - // Trigger cleanup after the Add operation is done - lock (_expirationQueue) - { - if (!_cleanupInProgress) - { - _cleanupInProgress = true; - - // Run cleanup asynchronously - CleanupExpiredItems().ContinueWith(t => - { - if (t.IsFaulted) - { - // Handle exceptions here - logger?.LogError(t.Exception, "An Exception occurred during cleanup expired items."); - } - }); - } - } - } - - /// - /// Determines whether the dictionary contains the specified key. - /// - /// - /// - public bool ContainsKey(TKey key) - { - lock (_dictionary) - { - return _dictionary.ContainsKey(key); - } - } - /// - /// Removes the value with the specified key from the dictionary. - /// - /// The key of the value to remove. - /// Returns true if the element is successfully found and removed; otherwise, false. - public bool Remove(TKey key) - { - lock (_dictionary) - { - if (_dictionary.Remove(key)) - { - lock (_expirationQueue) - { - // Find and remove the expiration time entry for this key - var expirationTime = _expirationQueue.FirstOrDefault(x => x.Value.Equals(key)).Key; - if (expirationTime != default) - { - _expirationQueue.Remove(expirationTime); - } - } - return true; - } - } - return false; - } - - /// - /// Get or set the value associated with the specified key. - /// - /// The key of the value to get or set. - /// The value associated with the specified key. - public TValue this[TKey key] - { - get - { - lock (_dictionary) - { - return _dictionary[key]; - } - } - set - { - lock (_dictionary) - { - _dictionary[key] = value; - var expirationTime = DateTime.Now.Add(expiration); - lock (_expirationQueue) - { - _expirationQueue[expirationTime] = key; - } - } - } - } - - /// - /// Cleans up expired items. - /// - /// A task that represents the asynchronous cleanup operation. - private async Task CleanupExpiredItems() - { - while (true) - { - DateTime nearestExpiration; - TKey expiredKey; - - // Safely lock and retrieve the first item to expire - lock (_expirationQueue) - { - // If there are no items, stop the cleanup process - if (_expirationQueue.Count == 0) - { - _cleanupInProgress = false; - return; - } - - // Get the first key in expiration queue (FIFO order) - var firstItem = _expirationQueue.First(); - nearestExpiration = firstItem.Key; - expiredKey = firstItem.Value; - } - - // Calculate the delay based on the expiration time - TimeSpan delay = nearestExpiration - DateTime.Now; - - if (delay > TimeSpan.Zero) - { - await Task.Delay(delay); - } - - // Remove the expired item from the dictionary - lock (_dictionary) - { - if (_dictionary.TryGetValue(expiredKey, out var expiredItem)) - { - ItemExpired?.Invoke(this, new ItemExpiredArgs(expiredItem)); - _dictionary.Remove(expiredKey); - } - } - - // Remove from expiration queue, but only if it's still the correct key - lock (_expirationQueue) - { - if (_expirationQueue.Count > 0 && _expirationQueue.First().Value.Equals(expiredKey)) - { - _expirationQueue.RemoveAt(0); // Safely remove the correct item - } - } - } - } - - #region NotImplemented - - /// - /// Not implemented. - /// - public ICollection Keys => throw new NotImplementedException(); - - /// - /// Not implemented. - /// - public ICollection Values => throw new NotImplementedException(); - - /// - /// Not implemented. - /// - public int Count => throw new NotImplementedException(); - - /// - /// Not implemented. - /// - public bool IsReadOnly => throw new NotImplementedException(); - - /// - /// Not implemented. - /// - public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) - { - throw new NotImplementedException(); - } - - /// - /// Not implemented. - /// - public void Add(KeyValuePair item) - { - throw new NotImplementedException(); - } - - /// - /// Not implemented. - /// - public void Clear() - { - throw new NotImplementedException(); - } - - /// - /// Not implemented. - /// - public bool Contains(KeyValuePair item) - { - throw new NotImplementedException(); - } - - /// - /// Not implemented. - /// - public void CopyTo(KeyValuePair[] array, int arrayIndex) - { - throw new NotImplementedException(); - } - - /// - /// Not implemented. - /// - public bool Remove(KeyValuePair item) - { - throw new NotImplementedException(); - } - - /// - /// Not implemented. - /// - public IEnumerator> GetEnumerator() - { - throw new NotImplementedException(); - } - - /// - /// Not implemented. - /// - IEnumerator IEnumerable.GetEnumerator() - { - throw new NotImplementedException(); - } - - #endregion -} \ No newline at end of file diff --git a/Jung.SimpleWebSocketTest/ClientHandlingFlowTest.cs b/Jung.SimpleWebSocketTest/ClientHandlingFlowTest.cs deleted file mode 100644 index 0e8916e..0000000 --- a/Jung.SimpleWebSocketTest/ClientHandlingFlowTest.cs +++ /dev/null @@ -1,108 +0,0 @@ -using Jung.SimpleWebSocket; -using Jung.SimpleWebSocket.Contracts; -using Jung.SimpleWebSocket.Exceptions; -using Jung.SimpleWebSocket.Flows; -using Jung.SimpleWebSocket.Models; -using Jung.SimpleWebSocketTest.Mock; -using Microsoft.Extensions.Logging; -using Moq; -using NUnit.Framework; -using System.Text; - -namespace Jung.SimpleWebSocketTest -{ - [TestFixture] - internal class ClientHandlingFlowTest - { - private ILogger _logger; - - [SetUp] - public void SetUp() - { - var loggerHelper = new ILoggerMockHelper("Server"); - _logger = loggerHelper.Logger; - } - - private ClientHandlingFlow SetupClientHandlingFlow(object serverOptions, List? activeClients = null) - { - var tcpListener = new Mock(); - var serverMoq = new Mock(serverOptions, tcpListener.Object, _logger); - if (activeClients != null) - { - foreach (var client in activeClients) - { - serverMoq.Object.ActiveClients.TryAdd(client.Id, client); - } - } - - var tcpClientMoq = new Mock(); - var serverClientMoq = new WebSocketServerClient(tcpClientMoq.Object); - - return new ClientHandlingFlow(serverMoq.Object, serverClientMoq, CancellationToken.None); - } - - private string CreateUpgradeRequest(string? userId = null) - { - var sb = new StringBuilder(); - sb.Append("GET /chat HTTP/1.1\r\n" + - "Host: localhost:8080\r\n" + - "Upgrade: websocket\r\n" + - "Connection: Upgrade\r\n" + - "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==\r\n" + - "Sec-WebSocket-Version: 13\r\n"); - - if (!string.IsNullOrEmpty(userId)) - { - sb.Append($"x-user-id: {userId}"); - } - - sb.Append("\r\n\r\n"); - return sb.ToString(); - } - - [Test] - public void HandleClientIdentification_NoNewUser_UserIdIsUpdated() - { - // setup - var userId = "6C8D0844-D84F-4AD9-B28D-23B3940887B7"; - var requestText = CreateUpgradeRequest(userId); - var serverOptions = new SimpleWebSocketServerOptions - { - RememberDisconnectedClients = true, - }; - - var clientHandlingFlow = SetupClientHandlingFlow(serverOptions); - clientHandlingFlow.Request = new WebContext(requestText); - - // act - clientHandlingFlow.HandleClientIdentification(); - - // assert - Assert.That(clientHandlingFlow.Client.Id, Is.EqualTo(userId)); - } - - [Test] - public void HandleClientIdentification_NoNewUser_UserAlreadyConnected() - { - // setup - var userId = "6C8D0844-D84F-4AD9-B28D-23B3940887B7"; - var activeUsers = new List(); - - var client = new WebSocketServerClient(); - client.UpdateId(userId); - activeUsers.Add(client); - - var requestText = CreateUpgradeRequest(userId); - var serverOptions = new SimpleWebSocketServerOptions - { - RememberDisconnectedClients = true, - }; - - // act and assert - var clientHandlingFlow = SetupClientHandlingFlow(serverOptions, activeUsers); - - clientHandlingFlow.Request = new WebContext(requestText); - Assert.That(() => clientHandlingFlow.HandleClientIdentification(), Throws.Exception.TypeOf()); - } - } -} 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 93% rename from Jung.SimpleWebSocket/Contracts/IWebSocketClient.cs rename to src/Jung.SimpleWebSocket/Contracts/IWebSocketClient.cs index 5bad1b4..1a73240 100644 --- a/Jung.SimpleWebSocket/Contracts/IWebSocketClient.cs +++ b/src/Jung.SimpleWebSocket/Contracts/IWebSocketClient.cs @@ -30,11 +30,6 @@ public interface IWebSocketClient : IDisposable /// bool IsConnected { get; } - /// - /// The user id of the client. If not set, the server did not sent a user id at websocket upgrade. - /// - string? UserId { get; } - /// /// Event that is raised when a message is received from a client. /// diff --git a/Jung.SimpleWebSocket/Contracts/IWebSocketServer.cs b/src/Jung.SimpleWebSocket/Contracts/IWebSocketServer.cs similarity index 87% rename from Jung.SimpleWebSocket/Contracts/IWebSocketServer.cs rename to src/Jung.SimpleWebSocket/Contracts/IWebSocketServer.cs index 5add6a8..a12093e 100644 --- a/Jung.SimpleWebSocket/Contracts/IWebSocketServer.cs +++ b/src/Jung.SimpleWebSocket/Contracts/IWebSocketServer.cs @@ -2,9 +2,9 @@ // 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 Jung.SimpleWebSocket.Utility; using System.Net; namespace Jung.SimpleWebSocket.Contracts; @@ -59,11 +59,6 @@ public interface IWebSocketServer : IDisposable /// event EventHandler? BinaryMessageReceived; - /// - /// Occurs when an passive user expired. - /// - event EventHandler? PassiveUserExpiredEvent; - /// /// Async Event that is raised when a client upgrade request is received. /// @@ -98,4 +93,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/Jung.SimpleWebSocket/Delegates/AsyncEventHandler.cs b/src/Jung.SimpleWebSocket/Delegates/AsyncEventHandler.cs similarity index 100% rename from Jung.SimpleWebSocket/Delegates/AsyncEventHandler.cs rename to src/Jung.SimpleWebSocket/Delegates/AsyncEventHandler.cs 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/Jung.SimpleWebSocket/Exceptions/UserNotHandledException.cs b/src/Jung.SimpleWebSocket/Exceptions/UserNotHandledException.cs similarity index 100% rename from Jung.SimpleWebSocket/Exceptions/UserNotHandledException.cs rename to src/Jung.SimpleWebSocket/Exceptions/UserNotHandledException.cs 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/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..58772f3 --- /dev/null +++ b/src/Jung.SimpleWebSocket/Flows/ClientHandlingFlow.cs @@ -0,0 +1,153 @@ +// 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); + } + + /// + /// 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); + + // 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) + { + // The client is rejected + await _upgradeHandler!.RejectWebSocketAsync(responseContext, _cancellationToken); + 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); + _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/Jung.SimpleWebSocket/Models/EventArguments/ClientDisconnectedArgs.cs b/src/Jung.SimpleWebSocket/Models/EventArguments/ClientDisconnectedArgs.cs similarity index 100% rename from Jung.SimpleWebSocket/Models/EventArguments/ClientDisconnectedArgs.cs rename to src/Jung.SimpleWebSocket/Models/EventArguments/ClientDisconnectedArgs.cs 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/Jung.SimpleWebSocket/Models/EventArguments/ClientUpgradeRequestReceivedArgs.cs b/src/Jung.SimpleWebSocket/Models/EventArguments/ClientUpgradeRequestReceivedArgs.cs similarity index 100% rename from Jung.SimpleWebSocket/Models/EventArguments/ClientUpgradeRequestReceivedArgs.cs rename to src/Jung.SimpleWebSocket/Models/EventArguments/ClientUpgradeRequestReceivedArgs.cs 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/SimpleWebSocketServerOptions.cs b/src/Jung.SimpleWebSocket/Models/SimpleWebSocketServerOptions.cs new file mode 100644 index 0000000..ea58fde --- /dev/null +++ b/src/Jung.SimpleWebSocket/Models/SimpleWebSocketServerOptions.cs @@ -0,0 +1,23 @@ +// 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; } + } +} diff --git a/Jung.SimpleWebSocket/Models/WebContext.cs b/src/Jung.SimpleWebSocket/Models/WebContext.cs similarity index 94% rename from Jung.SimpleWebSocket/Models/WebContext.cs rename to src/Jung.SimpleWebSocket/Models/WebContext.cs index 3b7626c..589ab61 100644 --- a/Jung.SimpleWebSocket/Models/WebContext.cs +++ b/src/Jung.SimpleWebSocket/Models/WebContext.cs @@ -238,9 +238,8 @@ private NameValueCollection ParseHeaders() /// The host name of the web request. /// The port of the web request. /// The request path of the web request. - /// The user id of the web request. /// The created web request context. - internal static WebContext CreateRequest(string hostName, int port, string requestPath, string? userId = null) + internal static WebContext CreateRequest(string hostName, int port, string requestPath) { var context = new WebContext() { @@ -249,12 +248,7 @@ internal static WebContext CreateRequest(string hostName, int port, string reque RequestPath = requestPath, }; - if (userId != null) - { - context.Headers.Add("x-user-id", userId); - } - - return context; + return context; } /// @@ -397,24 +391,6 @@ public static string GetStatusDescription(HttpStatusCode statusCode) return string.Join(" ", _splitByUppercaseRegex.Split(enumName)); } - - /// - /// Gets the user id of the web request. - /// - public string UserId - { - get - { - var userId = Headers["x-user-id"] ?? throw new WebSocketUpgradeException("UserId header is missing"); - return userId; - } - } - - /// - /// Gets a value indicating whether the web request contains a user id. - /// - public bool ContainsUserId => Headers["x-user-id"] != null; - /// /// 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 100% rename from Jung.SimpleWebSocket/Models/WebSocketServerClient.cs rename to src/Jung.SimpleWebSocket/Models/WebSocketServerClient.cs diff --git a/Jung.SimpleWebSocket/SimpleWebSocketClient.cs b/src/Jung.SimpleWebSocket/SimpleWebSocketClient.cs similarity index 95% rename from Jung.SimpleWebSocket/SimpleWebSocketClient.cs rename to src/Jung.SimpleWebSocket/SimpleWebSocketClient.cs index 2138cd6..6793e3d 100644 --- a/Jung.SimpleWebSocket/SimpleWebSocketClient.cs +++ b/src/Jung.SimpleWebSocket/SimpleWebSocketClient.cs @@ -22,9 +22,8 @@ namespace Jung.SimpleWebSocket /// The host name to connect to /// The port to connect to /// The web socket request path - /// The user id of the client. This is normally created by the server and sent back to the client /// A logger to write internal log messages - public class SimpleWebSocketClient(string hostName, int port, string requestPath, string? userId = null, ILogger? logger = null) : IWebSocketClient, IDisposable + public class SimpleWebSocketClient(string hostName, int port, string requestPath, ILogger? logger = null) : IWebSocketClient, IDisposable { /// public string HostName { get; } = hostName; @@ -33,9 +32,6 @@ public class SimpleWebSocketClient(string hostName, int port, string requestPath /// public string RequestPath { get; } = requestPath; - /// - public string? UserId { get; private set; } - /// public bool IsConnected => _client?.Connected ?? false; @@ -154,16 +150,11 @@ private async Task HandleWebSocketInitiation(TcpClientWrapper client, Cancellati _stream = client.GetStream(); var socketWrapper = new WebSocketUpgradeHandler(_stream); - var requestContext = WebContext.CreateRequest(HostName, Port, RequestPath, userId); + var requestContext = WebContext.CreateRequest(HostName, Port, RequestPath); await socketWrapper.SendUpgradeRequestAsync(requestContext, cancellationToken); var response = await socketWrapper.AwaitContextAsync(cancellationToken); WebSocketUpgradeHandler.ValidateUpgradeResponse(response, requestContext); - if (response.ContainsUserId) - { - UserId = response.UserId; - } - _webSocket = socketWrapper.CreateWebSocket(isServer: false); } diff --git a/Jung.SimpleWebSocket/SimpleWebSocketServer.cs b/src/Jung.SimpleWebSocket/SimpleWebSocketServer.cs similarity index 80% rename from Jung.SimpleWebSocket/SimpleWebSocketServer.cs rename to src/Jung.SimpleWebSocket/SimpleWebSocketServer.cs index 13d28b9..4b298cf 100644 --- a/Jung.SimpleWebSocket/SimpleWebSocketServer.cs +++ b/src/Jung.SimpleWebSocket/SimpleWebSocketServer.cs @@ -15,19 +15,24 @@ using System.Net; using System.Net.WebSockets; using System.Text; -using System.Threading; namespace Jung.SimpleWebSocket { /// /// A simple WebSocket server. /// - public class SimpleWebSocketServer : IWebSocketServer, IDisposable + /// + /// 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; } + public IPAddress LocalIpAddress { get; } = options.LocalIpAddress; /// - public int Port { get; } + public int Port { get; } = options.Port; /// public event EventHandler? ClientConnected; @@ -37,8 +42,6 @@ public class SimpleWebSocketServer : IWebSocketServer, IDisposable public event EventHandler? MessageReceived; /// public event EventHandler? BinaryMessageReceived; - /// - public event EventHandler? PassiveUserExpiredEvent; /// public event AsyncEventHandler? ClientUpgradeRequestReceivedAsync; @@ -48,11 +51,6 @@ public class SimpleWebSocketServer : IWebSocketServer, IDisposable /// internal ConcurrentDictionary ActiveClients { get; } = []; - /// - /// A dictionary of passive clients. - /// - internal IDictionary PassiveClients { get; set; } = null!; - /// public string[] ClientIds => [.. ActiveClients.Keys]; @@ -65,12 +63,12 @@ public class SimpleWebSocketServer : IWebSocketServer, IDisposable /// /// A logger to write internal log messages. /// - internal ILogger? Logger { get; } + internal ILogger? Logger { get; } = logger; /// /// The options for the server. /// - internal SimpleWebSocketServerOptions Options { get; } + internal SimpleWebSocketServerOptions Options { get; } = options; /// /// A flag indicating whether the server is started. @@ -92,48 +90,6 @@ public class SimpleWebSocketServer : IWebSocketServer, IDisposable /// 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(SimpleWebSocketServerOptions options, ILogger? logger = null) - { - LocalIpAddress = options.LocalIpAddress; - Port = options.Port; - Logger = logger; - Options = options; - InitializePassiveClientDictionary(options); - } - - /// - /// Initializes the passive clients dictionary. - /// - /// - private void InitializePassiveClientDictionary(SimpleWebSocketServerOptions options) - { - if (options.RememberDisconnectedClients) - { - // Initialize the passive clients dictionary - if (options.RemovePassiveClientsAfterClientExpirationTime) - { - var passiveClients = new ExpiringDictionary(options.PassiveClientLifetime, Logger); - passiveClients.ItemExpired += PassiveClients_ItemExpired; - PassiveClients = passiveClients; - } - else - { - PassiveClients = new Dictionary(); - } - } - else - { - // If user handling is not activated, the passive clients are not needed - PassiveClients = null!; - } - } - /// /// Initializes a new instance of the class that listens /// for incoming connection attempts on the specified local IP address and port number. @@ -257,6 +213,20 @@ public WebSocketServerClient GetClientById(string clientId) return client; } + /// + public void ChangeClientId(WebSocketServerClient client, string newId) + { + // 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. /// @@ -271,10 +241,7 @@ private async Task HandleClientAsync(WebSocketServerClient client, CancellationT // Load the request context await flow.LoadRequestContext(); - // Handle the client user identification if activated - flow.HandleClientIdentification(); - - // raise async client upgrade request received event + // Raise async client upgrade request received event var eventArgs = await flow.RaiseUpgradeEventAsync(ClientUpgradeRequestReceivedAsync); // Respond to the upgrade request @@ -282,6 +249,7 @@ private async Task HandleClientAsync(WebSocketServerClient client, CancellationT { // Accept the WebSocket connection await flow.AcceptWebSocketAsync(); + if (flow.TryAddClientToActiveUserList()) { Logger?.LogDebug("Connection upgraded, now listening on Client {clientId}", flow.Client.Id); @@ -291,7 +259,7 @@ private async Task HandleClientAsync(WebSocketServerClient client, CancellationT } else { - Logger?.LogDebug("Connection upgraded, now listening on Client {clientId}", flow.Client.Id); + Logger?.LogDebug("Error while adding Client {clientId} to active clients", flow.Client.Id); } } else @@ -372,23 +340,6 @@ private async Task ProcessWebSocketMessagesAsync(WebSocketServerClient client, C } } - /// - /// Handles the event when a passive user expired. - /// - /// - /// Condition: is set to true. - /// - /// The sender of the event () - /// The arguments of the event - private void PassiveClients_ItemExpired(object? sender, ItemExpiredArgs e) - { - Logger?.LogDebug("Passive Client expired: {clientId}", e.Item.Id); - - // Raise the event asynchronously - // We don't want to block the cleanup process - AsyncEventRaiser.RaiseAsyncInNewTask(PassiveUserExpiredEvent, this, new PassiveUserExpiredArgs(e.Item.Id), _cancellationTokenSource.Token); - } - /// public void Dispose() { diff --git a/Jung.SimpleWebSocket/Utility/AsyncEventRaiser.cs b/src/Jung.SimpleWebSocket/Utility/AsyncEventRaiser.cs similarity index 100% rename from Jung.SimpleWebSocket/Utility/AsyncEventRaiser.cs rename to src/Jung.SimpleWebSocket/Utility/AsyncEventRaiser.cs diff --git a/Jung.SimpleWebSocket/Wrappers/NetworkStreamWrapper.cs b/src/Jung.SimpleWebSocket/Wrappers/NetworkStreamWrapper.cs similarity index 100% rename from Jung.SimpleWebSocket/Wrappers/NetworkStreamWrapper.cs rename to src/Jung.SimpleWebSocket/Wrappers/NetworkStreamWrapper.cs 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 100% rename from Jung.SimpleWebSocket/Wrappers/TcpListenerWrapper.cs rename to src/Jung.SimpleWebSocket/Wrappers/TcpListenerWrapper.cs diff --git a/Jung.SimpleWebSocket/Wrappers/WebSocketUpgradeHandler.cs b/src/Jung.SimpleWebSocket/Wrappers/WebSocketUpgradeHandler.cs similarity index 96% rename from Jung.SimpleWebSocket/Wrappers/WebSocketUpgradeHandler.cs rename to src/Jung.SimpleWebSocket/Wrappers/WebSocketUpgradeHandler.cs index 416a4aa..5d9f87b 100644 --- a/Jung.SimpleWebSocket/Wrappers/WebSocketUpgradeHandler.cs +++ b/src/Jung.SimpleWebSocket/Wrappers/WebSocketUpgradeHandler.cs @@ -22,11 +22,10 @@ internal partial class WebSocketUpgradeHandler { private const string _supportedVersion = "13"; private const string _webSocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; - private const string _userIdHeaderName = "x-user-id"; 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)] @@ -36,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) @@ -63,7 +62,7 @@ public async Task AwaitContextAsync(CancellationToken cancellationTo return context; } - public async Task AcceptWebSocketAsync(WebContext request, WebContext response, string userId, string? subProtocol, CancellationToken cancellationToken) + public async Task AcceptWebSocketAsync(WebContext request, WebContext response, string? subProtocol, CancellationToken cancellationToken) { try { @@ -79,7 +78,6 @@ public async Task AcceptWebSocketAsync(WebContext request, WebContext response, response.Headers.Add("Upgrade", "websocket"); response.Headers.Add("Sec-WebSocket-Accept", secWebSocketAcceptString); response.StatusCode = HttpStatusCode.SwitchingProtocols; - response.Headers.Add(_userIdHeaderName, userId); await SendWebSocketResponseHeaders(response, cancellationToken); _acceptedProtocol = subProtocol; } @@ -89,7 +87,7 @@ public async Task AcceptWebSocketAsync(WebContext request, WebContext response, } catch (Exception message) { - throw new WebSocketException("Error while accepting the websocket", message); + throw new WebSocketException("Error while accepting the web socket", message); } } @@ -296,7 +294,7 @@ 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) 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 100% rename from Jung.SimpleWebSocket/docs/README.md rename to src/Jung.SimpleWebSocket/docs/README.md 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/Jung.SimpleWebSocketTest/Mock/ILoggerMockHelper.cs b/tests/Jung.SimpleWebSocket.UnitTests/Mock/ILoggerMockHelper.cs similarity index 87% rename from Jung.SimpleWebSocketTest/Mock/ILoggerMockHelper.cs rename to tests/Jung.SimpleWebSocket.UnitTests/Mock/ILoggerMockHelper.cs index e72d2fa..b784ee1 100644 --- a/Jung.SimpleWebSocketTest/Mock/ILoggerMockHelper.cs +++ b/tests/Jung.SimpleWebSocket.UnitTests/Mock/ILoggerMockHelper.cs @@ -1,7 +1,10 @@ -using Microsoft.Extensions.Logging; +// 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.SimpleWebSocketTest.Mock +namespace Jung.SimpleWebSocket.UnitTests.Mock { internal class ILoggerMockHelper where T : class { diff --git a/Jung.SimpleWebSocketTest/Mock/LoggerMessages.cs b/tests/Jung.SimpleWebSocket.UnitTests/Mock/LoggerMessages.cs similarity index 76% rename from Jung.SimpleWebSocketTest/Mock/LoggerMessages.cs rename to tests/Jung.SimpleWebSocket.UnitTests/Mock/LoggerMessages.cs index b5cc9b3..86af14e 100644 --- a/Jung.SimpleWebSocketTest/Mock/LoggerMessages.cs +++ b/tests/Jung.SimpleWebSocket.UnitTests/Mock/LoggerMessages.cs @@ -1,5 +1,7 @@ - -namespace Jung.SimpleWebSocketTest.Mock +// 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 { diff --git a/Jung.SimpleWebSocketTest/SimpleWebSocketTest.cs b/tests/Jung.SimpleWebSocket.UnitTests/SimpleWebSocketTest.cs similarity index 82% rename from Jung.SimpleWebSocketTest/SimpleWebSocketTest.cs rename to tests/Jung.SimpleWebSocket.UnitTests/SimpleWebSocketTest.cs index e86696c..2621a9f 100644 --- a/Jung.SimpleWebSocketTest/SimpleWebSocketTest.cs +++ b/tests/Jung.SimpleWebSocket.UnitTests/SimpleWebSocketTest.cs @@ -1,18 +1,18 @@ // This file is part of the Jung SimpleWebSocket project. // The project is licensed under the MIT license. -using Jung.SimpleWebSocket; +using Jung.SimpleWebSocket.Exceptions; using Jung.SimpleWebSocket.Models; -using Jung.SimpleWebSocketTest.Mock; +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 @@ -39,7 +39,80 @@ public void EndTest() Trace.Flush(); } + [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() + { + // Arrange + var serverOptions = new SimpleWebSocketServerOptions + { + 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] [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.")] @@ -50,7 +123,6 @@ public async Task TestClientServerConnection_ShouldSendAndReceiveHelloWorld() { LocalIpAddress = IPAddress.Any, Port = 8010, - RememberDisconnectedClients = true, }; using var server = new SimpleWebSocketServer(serverOptions, _serverLoggerMockHelper.Logger); @@ -61,7 +133,6 @@ public async Task TestClientServerConnection_ShouldSendAndReceiveHelloWorld() const string ClosingStatusDescription = "closing status test description"; string receivedMessage = string.Empty; string receivedClosingDescription = string.Empty; - string exceptionMessage = string.Empty; var messageResetEvent = new ManualResetEvent(false); var disconnectResetEvent = new ManualResetEvent(false); @@ -123,20 +194,10 @@ public async Task TestClientServerConnection_ShouldSendAndReceiveHelloWorld() WaitForManualResetEventOrThrow(disconnectResetEvent); // test if the server accepts the client again - var client2 = new SimpleWebSocketClient(IPAddress.Loopback.ToString(), 8010, "/", client.UserId, logger: _clientLoggerMockHelper.Logger); + var client2 = new SimpleWebSocketClient(IPAddress.Loopback.ToString(), 8010, "/", logger: _clientLoggerMockHelper.Logger); await client2.ConnectAsync(); await Task.Delay(100); - try - { - // test if two clients with the same user id can connect - var client3 = new SimpleWebSocketClient(IPAddress.Loopback.ToString(), 8010, "/", client.UserId, logger: _clientLoggerMockHelper.Logger); - await client3.ConnectAsync(); - } - catch (Exception exception) - { - exceptionMessage = exception.InnerException!.Message; - } await client2.SendMessageAsync("Hello World"); @@ -148,7 +209,6 @@ public async Task TestClientServerConnection_ShouldSendAndReceiveHelloWorld() { Assert.That(receivedMessage, Is.EqualTo(Message)); Assert.That(receivedClosingDescription, Is.EqualTo(ClosingStatusDescription)); - Assert.That(exceptionMessage, Does.Contain("User id already in use")); }); } @@ -165,49 +225,6 @@ private static async Task DbContext_IpAddresses_Contains(IPAddress ipAddre 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_ShouldRemoveClientFromPassiveClients() - { - // Arrange - string userId = Guid.NewGuid().ToString(); - var serverOptions = new SimpleWebSocketServerOptions - { - LocalIpAddress = IPAddress.Any, - Port = 8010, - RememberDisconnectedClients = true, - RemovePassiveClientsAfterClientExpirationTime = true, - PassiveClientLifetime = TimeSpan.FromSeconds(1) - }; - - using var server = new SimpleWebSocketServer(serverOptions, _serverLoggerMockHelper.Logger); - using var client = new SimpleWebSocketClient(IPAddress.Loopback.ToString(), 8010, "/", userId, _clientLoggerMockHelper.Logger); - - var expiredClientId = string.Empty; - var expiredClientResetEvent = new ManualResetEvent(false); - - server.PassiveUserExpiredEvent += (sender, args) => - { - expiredClientId = args.ClientId; - expiredClientResetEvent.Set(); - }; - - // Act - server.Start(); - await client.ConnectAsync(); - await Task.Delay(100); - await client.DisconnectAsync(); - - WaitForManualResetEventOrThrow(expiredClientResetEvent, 2000); - - await server.ShutdownServer(CancellationToken.None); - Array.ForEach(LoggerMessages.GetMessages(), m => Trace.WriteLine(m)); - - // Assert - Assert.That(expiredClientId, Is.EqualTo(userId)); - } - [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() 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 96% rename from Jung.SimpleWebSocketTest/WebSocketUpgradeHandlerTests.cs rename to tests/Jung.SimpleWebSocket.UnitTests/WebSocketUpgradeHandlerTests.cs index aa69078..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,new WebContext(), Guid.NewGuid().ToString(), null, 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, new WebContext(), Guid.NewGuid().ToString(), serverSubprotocol, cancellationToken); + await _socketWrapper.AcceptWebSocketAsync(request, new WebContext(), serverSubprotocol, cancellationToken); // Assert Assert.Multiple(() =>