From abab2be4f41316c1623af19febe6b954e9eaa3d4 Mon Sep 17 00:00:00 2001 From: Dmitri Shimanski Date: Sat, 29 Mar 2025 02:34:08 +0300 Subject: [PATCH] Create project files --- .dockerignore | 25 +++ .github/workflows/dotnet.yml | 23 +++ .github/workflows/nuget.yml | 48 +++++ .gitignore | 5 + .../.idea/.gitignore | 13 ++ .../.idea/encodings.xml | 4 + Examples/ChatController.cs | 17 ++ Examples/Dockerfile | 21 ++ Examples/Examples.csproj | 22 +++ Examples/Program.cs | 42 ++++ Examples/TestWebSocketServer.cs | 18 ++ Examples/appsettings.json | 1 + LICENSE | 0 README.md | 121 ++++++++++++ global.json | 7 + .../Core/WebSocketManagerTest.cs | 162 ++++++++++++++++ .../ServiceBindingsTest.cs | 12 ++ .../yawaflua.WebSockets.Tests.csproj | 23 +++ yawaflua.WebSockets.sln | 28 +++ .../Attributes/WebSocketAttribute.cs | 40 ++++ .../Core/Middleware/WebSocketMiddleware.cs | 25 +++ yawaflua.WebSockets/Core/WebSocket.cs | 43 +++++ yawaflua.WebSockets/Core/WebSocketManager.cs | 34 ++++ yawaflua.WebSockets/Core/WebSocketRouter.cs | 182 ++++++++++++++++++ .../Models/Abstracts/WebSocketController.cs | 16 ++ .../Models/Interfaces/IWebSocketClient.cs | 15 ++ .../Models/Interfaces/IWebSocketController.cs | 9 + .../Models/Interfaces/IWebSocketManager.cs | 11 ++ yawaflua.WebSockets/Models/WebSocketClient.cs | 34 ++++ .../Properties/AssemblyInfo.cs | 1 + yawaflua.WebSockets/ServiceBindings.cs | 25 +++ .../yawaflua.WebSockets.csproj | 30 +++ 32 files changed, 1057 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/dotnet.yml create mode 100644 .github/workflows/nuget.yml create mode 100644 .gitignore create mode 100644 .idea/.idea.yawaflua.WebSockets/.idea/.gitignore create mode 100644 .idea/.idea.yawaflua.WebSockets/.idea/encodings.xml create mode 100644 Examples/ChatController.cs create mode 100644 Examples/Dockerfile create mode 100644 Examples/Examples.csproj create mode 100644 Examples/Program.cs create mode 100644 Examples/TestWebSocketServer.cs create mode 100644 Examples/appsettings.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 global.json create mode 100644 yawaflua.WebSockets.Tests/Core/WebSocketManagerTest.cs create mode 100644 yawaflua.WebSockets.Tests/ServiceBindingsTest.cs create mode 100644 yawaflua.WebSockets.Tests/yawaflua.WebSockets.Tests.csproj create mode 100644 yawaflua.WebSockets.sln create mode 100644 yawaflua.WebSockets/Attributes/WebSocketAttribute.cs create mode 100644 yawaflua.WebSockets/Core/Middleware/WebSocketMiddleware.cs create mode 100644 yawaflua.WebSockets/Core/WebSocket.cs create mode 100644 yawaflua.WebSockets/Core/WebSocketManager.cs create mode 100644 yawaflua.WebSockets/Core/WebSocketRouter.cs create mode 100644 yawaflua.WebSockets/Models/Abstracts/WebSocketController.cs create mode 100644 yawaflua.WebSockets/Models/Interfaces/IWebSocketClient.cs create mode 100644 yawaflua.WebSockets/Models/Interfaces/IWebSocketController.cs create mode 100644 yawaflua.WebSockets/Models/Interfaces/IWebSocketManager.cs create mode 100644 yawaflua.WebSockets/Models/WebSocketClient.cs create mode 100644 yawaflua.WebSockets/Properties/AssemblyInfo.cs create mode 100644 yawaflua.WebSockets/ServiceBindings.cs create mode 100644 yawaflua.WebSockets/yawaflua.WebSockets.csproj diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd967fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..8cc3d67 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,23 @@ +name: .NET Tests + +on: + push: + branches: [ "*" ] + pull_request: + branches: [ "*" ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: [6.0.x, 7.0.x, 8.0.x, 9.0.x] + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore /p:TreatWarningsAsErrors=false + - name: Test + run: dotnet test --no-build --verbosity normal \ No newline at end of file diff --git a/.github/workflows/nuget.yml b/.github/workflows/nuget.yml new file mode 100644 index 0000000..60258ce --- /dev/null +++ b/.github/workflows/nuget.yml @@ -0,0 +1,48 @@ +name: CI/CD NuGet Package + +on: + release: + types: [created] + +env: + NUGET_SOURCE: https://api.nuget.org/v3/index.json + GITHUB_SOURCE: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json + +jobs: + build-and-publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 7.0.x + + - name: Extract Package Version + id: get_version + run: | + VERSION=${GITHUB_REF#refs/tags/v} + echo "PACKAGE_VERSION=$VERSION" >> $GITHUB_ENV + echo "Using version: $VERSION" + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Test + run: dotnet test --no-build --verbosity normal --configuration Release + + - name: Pack + run: dotnet pack --no-build --configuration Release -p:PackageVersion=${{ env.PACKAGE_VERSION }} + - name: Add GitHub source + run: dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.GHCR_TOKEN }} --store-password-in-clear-text --name github ${{ env.GITHUB_SOURCE }} + + - name: Publish to GitHub Packages + run: dotnet nuget push "**/*.nupkg" --source "github" + + - name: Publish to NuGet + run: dotnet nuget push "**/*.nupkg" --source ${{ env.NUGET_SOURCE }} --api-key ${{ secrets.NUGET_API_KEY }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..add57be --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/.idea/.idea.yawaflua.WebSockets/.idea/.gitignore b/.idea/.idea.yawaflua.WebSockets/.idea/.gitignore new file mode 100644 index 0000000..0bafb71 --- /dev/null +++ b/.idea/.idea.yawaflua.WebSockets/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/contentModel.xml +/.idea.yawaflua.WebSockets.iml +/projectSettingsUpdater.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.yawaflua.WebSockets/.idea/encodings.xml b/.idea/.idea.yawaflua.WebSockets/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.yawaflua.WebSockets/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Examples/ChatController.cs b/Examples/ChatController.cs new file mode 100644 index 0000000..b621029 --- /dev/null +++ b/Examples/ChatController.cs @@ -0,0 +1,17 @@ +using yawaflua.WebSockets.Attributes; +using yawaflua.WebSockets.Models.Abstracts; +using WebSocket = yawaflua.WebSockets.Core.WebSocket; + +namespace Examples; + +[WebSocket("/chat")] +public class ChatController : WebSocketController +{ + + public override async Task OnMessageAsync( + WebSocket webSocket, + HttpContext httpContext) + { + await WebSocketManager.Broadcast(k => k.Path == "/chat", $"{webSocket.Client.Id}: {webSocket.Message}"); + } +} \ No newline at end of file diff --git a/Examples/Dockerfile b/Examples/Dockerfile new file mode 100644 index 0000000..d67d305 --- /dev/null +++ b/Examples/Dockerfile @@ -0,0 +1,21 @@ +FROM mcr.microsoft.com/dotnet/runtime:9.0 AS base +USER $APP_UID +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["Examples/Examples.csproj", "Examples/"] +RUN dotnet restore "Examples/Examples.csproj" +COPY . . +WORKDIR "/src/Examples" +RUN dotnet build "Examples.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "Examples.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Examples.dll"] diff --git a/Examples/Examples.csproj b/Examples/Examples.csproj new file mode 100644 index 0000000..6288d99 --- /dev/null +++ b/Examples/Examples.csproj @@ -0,0 +1,22 @@ + + + + Exe + net9.0 + enable + enable + Linux + false + + + + + .dockerignore + + + + + + + + diff --git a/Examples/Program.cs b/Examples/Program.cs new file mode 100644 index 0000000..e0a1396 --- /dev/null +++ b/Examples/Program.cs @@ -0,0 +1,42 @@ +using yawaflua.WebSockets; + +namespace Examples; + +class Program +{ + static async Task Main(string[] args) + { + await Host.CreateDefaultBuilder(args) + .ConfigureLogging(k => k.AddConsole().AddDebug()) + .ConfigureWebHost(k => + { + k.UseKestrel(l => l.ListenAnyIP(80)); + k.UseStartup(); + }) + .RunConsoleAsync(); + } +} + +internal class Startup +{ + public void ConfigureServices(IServiceCollection services) + { + services.SettingUpWebSockets(); + services.AddRouting(); + services.AddHttpLogging(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(k => new ConfigurationBuilder() + .AddJsonFile("appsettings.json", true) + .Build()); + } + + public void Configure(IApplicationBuilder app) + { + app.ConnectWebSockets(); + app.UseRouting(); + app.UseHttpLogging(); + app.UseWelcomePage(); + + } +} \ No newline at end of file diff --git a/Examples/TestWebSocketServer.cs b/Examples/TestWebSocketServer.cs new file mode 100644 index 0000000..621e09f --- /dev/null +++ b/Examples/TestWebSocketServer.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using yawaflua.WebSockets.Attributes; +using yawaflua.WebSockets.Core; +using yawaflua.WebSockets.Models.Abstracts; + +namespace Examples; + +[WebSocket("/test")] +public class TestWebSocketServer : WebSocketController +{ + + [WebSocket("/sub-test")] + public override async Task OnMessageAsync(WebSocket webSocket, HttpContext httpContext) + { + await webSocket.SendAsync("Test! Now on it endpoint: " + WebSocketManager.GetAllClients().Count(k => k.Path == webSocket.Client.Path)); + } +} \ No newline at end of file diff --git a/Examples/appsettings.json b/Examples/appsettings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/Examples/appsettings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..de4b84e --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# New WebSocket routing system + +## Features +- ASP.NET Core-style WebSocket routing 🛣️ +- Method-based endpoint handlers (Like in ASP!) 🎯 +- Simple integration with existing applications ⚡ + +## Installation +Add the NuGet package to your project: +```bash +dotnet add package yawaflua.WebSockets +``` + +## Quick Start + +### 1. Create WebSocket Controller +```csharp +public class ChatController : WebSocketController +{ + [WebSocket("/chat")] + public override async Task OnMessageAsync( + WebSocket webSocket, + HttpContext httpContext) + { + await webSocket.SendAsync("Message!"); + } +} +``` + +### 2. Configure Services +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services + .AddControllers() + .SettingUpWebSockets(); // ← Add WebSocket routing + + services.AddSingleton(); +} +``` + +### 3. Enable Middleware +```csharp +public void Configure(IApplicationBuilder app) +{ + app.ConnectWebSockets(); // ← Add WebSocket handling + + app.UseRouting(); + app.UseEndpoints(endpoints => endpoints.MapControllers()); +} +``` + +## Advanced Usage + +### Parameterized Routes +```csharp +public class NotificationsController : WebSocketController +{ + [WebSocket("/notifications/{userId}")] + public override async Task OnMessageAsync( + WebSocket webSocket, + HttpContext httpContext) + { + var userId = httpContext.Request.RouteValues["userId"]; + // Handle user-specific notifications + } +} +``` + +### Method-Level Routes +```csharp +[WebSocket("/game")] +public class GameController : WebSocketController +{ + [WebSocket("join/{roomId}")] + public async Task JoinRoom(WebSocket webSocket, HttpContext context) + { + // Handle room joining + } + + [WebSocket("leave/{roomId}")] + public async Task LeaveRoom(WebSocket webSocket, HttpContext context) + { + // Handle room leaving + } +} +``` + +## Lifecycle Management +1. **Connection** - Automatically handled by middleware +2. **Message Handling** - Implement `OnMessageAsync` +3. **Cleanup** - Dispose resources in `IDisposable` interface + +## Best Practices +1. **Keep Controllers Light** - Move business logic to services +2. **Use Dependency Injection** - Inject services via constructor +3. **Handle Exceptions** - Wrap operations in try/catch blocks +4. **Manage State** - Use `HttpContext.Items` for request-scoped data + +## Troubleshooting +**No Route Handling?** +- Verify controller registration in DI: + ```csharp + services.AddSingleton(); + ``` + +**Connection Issues?** +- Ensure middleware order: + ```csharp + app.ConnectWebSockets(); // Must be before UseRouting/UseEndpoints + ``` + +**Parameters Not Working?** +- Check route template syntax: + ```csharp + [WebSocket("/correct/{paramName}")] // ✓ + [WebSocket("/wrong/{param-name}")] // ✗ + ``` + +## License +MIT License - Free for commercial and personal use. \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..93681ff --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "9.0.0", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/yawaflua.WebSockets.Tests/Core/WebSocketManagerTest.cs b/yawaflua.WebSockets.Tests/Core/WebSocketManagerTest.cs new file mode 100644 index 0000000..9bb9fdc --- /dev/null +++ b/yawaflua.WebSockets.Tests/Core/WebSocketManagerTest.cs @@ -0,0 +1,162 @@ +using yawaflua.WebSockets.Core; +using System; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using yawaflua.WebSockets.Attributes; +using yawaflua.WebSockets.Models.Abstracts; +using Assert = Xunit.Assert; +using WebSocket = yawaflua.WebSockets.Core.WebSocket; +using WebSocketManager = Microsoft.AspNetCore.Http.WebSocketManager; + +namespace yawaflua.WebSockets.Tests.Core; + + +[TestSubject(typeof(WebSocketRouter))] +public class WebSocketRouterTests +{ + private readonly Mock _serviceProviderMock = new(); + private readonly Mock> _loggerMock = new(); + private IServiceCollection _services; + + public WebSocketRouterTests() + { + _services = new ServiceCollection(); + _serviceProviderMock.Setup(k => k.GetService(typeof(IServiceScopeFactory))) + .Returns(_services.BuildServiceProvider().CreateScope()); + } + [yawaflua.WebSockets.Attributes.WebSocket("/test")] + public class TestHandler : WebSocketController + { + [yawaflua.WebSockets.Attributes.WebSocket("/static")] + public static Task StaticHandler(WebSocket ws, HttpContext context) => Task.CompletedTask; + + [yawaflua.WebSockets.Attributes.WebSocket("/instance")] + public Task InstanceHandler(WebSocket ws, HttpContext context) => Task.CompletedTask; + } + + [Fact] + public void DiscoverHandlers_ShouldRegisterStaticAndInstanceMethods() + { + // Arrange + _services.AddTransient(); + _serviceProviderMock.Setup(x => x.GetService(typeof(TestHandler))) + .Returns(new TestHandler()); + // Act + var router = new WebSocketRouter(_serviceProviderMock.Object, _loggerMock.Object); + + // Assert + Assert.True(WebSocketRouter.Routes.ContainsKey("/test/static")); + Assert.True(WebSocketRouter.Routes.ContainsKey("/test/instance")); + } + + [Fact] + public async Task HandleRequest_ShouldAcceptWebSocketAndAddClient() + { + // Arrange + var webSocketMock = new Mock(); + var contextMock = new Mock(); + var webSocketManagerMock = new Mock() { CallBase = true }; + + webSocketManagerMock.Setup(m => m.AcceptWebSocketAsync()) + .ReturnsAsync(webSocketMock.Object); + webSocketManagerMock.Setup(m => m.IsWebSocketRequest) + .Returns(true); + contextMock.SetupGet(c => c.WebSockets).Returns(webSocketManagerMock.Object); + contextMock.SetupGet(c => c.Request.Path).Returns(new PathString("/test/static")); + contextMock.Setup(c => c.RequestServices) + .Returns(_serviceProviderMock.Object); + + var router = new WebSocketRouter(_services.BuildServiceProvider(), _loggerMock.Object); + + // Act + await router.HandleRequest(contextMock.Object); + + // Assert + Assert.Single(WebSocketRouter.Clients); + } + + [Fact] + public async Task HandleRequest_ShouldReturn404ForUnknownPath() + { + // Arrange + var contextMock = new Mock(); + var responseMock = new Mock(); + var webSocketManagerMock = new Mock(); + + webSocketManagerMock.Setup(m => m.IsWebSocketRequest).Returns(true); + contextMock.SetupGet(c => c.WebSockets).Returns(webSocketManagerMock.Object); + contextMock.SetupGet(c => c.Request.Path).Returns(new PathString("/unknown")); + contextMock.SetupGet(c => c.Response).Returns(responseMock.Object); + + var router = new WebSocketRouter(_services.BuildServiceProvider(), _loggerMock.Object); + + // Act + await router.HandleRequest(contextMock.Object); + + // Assert + responseMock.VerifySet(r => r.StatusCode = 404); + } + + [Fact] + public void DiscoverHandlers_ShouldLogErrorOnInvalidHandler() + { + // Arrange + var invalidHandlerType = typeof(InvalidHandler); + _serviceProviderMock.Setup(x => x.GetService(invalidHandlerType)) + .Throws(new InvalidOperationException()); + + // Act + var router = new WebSocketRouter(_serviceProviderMock.Object, _loggerMock.Object); + + // Assert + _loggerMock.Verify( + x => x.Log( + LogLevel.Critical, + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), + Times.AtLeastOnce); + } + + [WebSocket("/invalid")] + public class InvalidHandler : WebSocketController + { + [WebSocket("/handler")] + public void InvalidMethod() { } // Invalid signature + } + + [Fact] + public async Task Client_ShouldBeRemovedOnConnectionClose() + { + // Arrange + var webSocketMock = new Mock(); + webSocketMock.Setup(ws => ws.State).Returns(WebSocketState.Open); + webSocketMock.Setup(ws => ws.ReceiveAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new WebSocketReceiveResult(0, WebSocketMessageType.Close, true)); + + var contextMock = new Mock(); + var webSocketManagerMock = new Mock(); + + webSocketManagerMock.Setup(m => m.AcceptWebSocketAsync()).ReturnsAsync(webSocketMock.Object); + contextMock.SetupGet(c => c.WebSockets).Returns(webSocketManagerMock.Object); + contextMock.SetupGet(c => c.Request.Path).Returns(new PathString("/test/static")); + contextMock.Setup(c => c.RequestServices).Returns(_serviceProviderMock.Object); + + var router = new WebSocketRouter(_serviceProviderMock.Object, _loggerMock.Object); + + // Act + await router.HandleRequest(contextMock.Object); + await Task.Delay(100); // Allow background task to complete + + // Assert + Assert.Empty(WebSocketRouter.Clients); + } +} diff --git a/yawaflua.WebSockets.Tests/ServiceBindingsTest.cs b/yawaflua.WebSockets.Tests/ServiceBindingsTest.cs new file mode 100644 index 0000000..4ac24a7 --- /dev/null +++ b/yawaflua.WebSockets.Tests/ServiceBindingsTest.cs @@ -0,0 +1,12 @@ +using yawaflua.WebSockets; + +namespace yawaflua.WebSockets.Tests; + +public class ServiceBindingsTest +{ + + public void TestAddingInServiceProviderNewHosts() + { + + } +} \ No newline at end of file diff --git a/yawaflua.WebSockets.Tests/yawaflua.WebSockets.Tests.csproj b/yawaflua.WebSockets.Tests/yawaflua.WebSockets.Tests.csproj new file mode 100644 index 0000000..91fabae --- /dev/null +++ b/yawaflua.WebSockets.Tests/yawaflua.WebSockets.Tests.csproj @@ -0,0 +1,23 @@ + + + + net9.0 + + false + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/yawaflua.WebSockets.sln b/yawaflua.WebSockets.sln new file mode 100644 index 0000000..a693e97 --- /dev/null +++ b/yawaflua.WebSockets.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "yawaflua.WebSockets", "yawaflua.WebSockets\yawaflua.WebSockets.csproj", "{D9E47608-7769-45DD-8E35-4DA90127D9E0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Examples", "Examples\Examples.csproj", "{D90CE32E-BF1F-4129-8FB1-0DD6BD816830}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "yawaflua.WebSockets.Tests", "yawaflua.WebSockets.Tests\yawaflua.WebSockets.Tests.csproj", "{B4B37A81-4147-4C19-91BC-D897CAEBEE6A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D9E47608-7769-45DD-8E35-4DA90127D9E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D9E47608-7769-45DD-8E35-4DA90127D9E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D9E47608-7769-45DD-8E35-4DA90127D9E0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D9E47608-7769-45DD-8E35-4DA90127D9E0}.Release|Any CPU.Build.0 = Release|Any CPU + {D90CE32E-BF1F-4129-8FB1-0DD6BD816830}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D90CE32E-BF1F-4129-8FB1-0DD6BD816830}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D90CE32E-BF1F-4129-8FB1-0DD6BD816830}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D90CE32E-BF1F-4129-8FB1-0DD6BD816830}.Release|Any CPU.Build.0 = Release|Any CPU + {B4B37A81-4147-4C19-91BC-D897CAEBEE6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4B37A81-4147-4C19-91BC-D897CAEBEE6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4B37A81-4147-4C19-91BC-D897CAEBEE6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4B37A81-4147-4C19-91BC-D897CAEBEE6A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/yawaflua.WebSockets/Attributes/WebSocketAttribute.cs b/yawaflua.WebSockets/Attributes/WebSocketAttribute.cs new file mode 100644 index 0000000..1c603e9 --- /dev/null +++ b/yawaflua.WebSockets/Attributes/WebSocketAttribute.cs @@ -0,0 +1,40 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.Mvc; + +namespace yawaflua.WebSockets.Attributes; + +/// +/// Attribute that marks WebSocket endpoint handlers. +/// Applied to classes or methods to define WebSocket route templates. +/// +/// Usage examples: +/// [WebSocket("/chat")] - basic route +/// [WebSocket("/notifications/{userId}")] - route with parameter +/// [WebSocket("/game/{roomId}/players")] - complex route +/// +/// +/// Inherits from ASP.NET Core's RouteAttribute to leverage standard routing syntax. +/// When applied to a class, defines the base path for all WebSocket endpoints in controller. +/// When applied to methods, defines specific sub-routes (requires class-level base path). +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public class WebSocketAttribute : RouteAttribute +{ + /// + /// Original route template specified in attribute + /// + public string Template { get; } + + /// + /// Creates WebSocket route definition + /// + /// Route template using ASP.NET Core syntax: + /// - Static: "/status" + /// - Parameters: "/user/{id}" + /// - Constraints: "/file/{name:alpha}" + /// - Optional: "/feed/{category?}" + public WebSocketAttribute([RouteTemplate]string path) : base(path) + { + Template = path; + } +} \ No newline at end of file diff --git a/yawaflua.WebSockets/Core/Middleware/WebSocketMiddleware.cs b/yawaflua.WebSockets/Core/Middleware/WebSocketMiddleware.cs new file mode 100644 index 0000000..8a88e3e --- /dev/null +++ b/yawaflua.WebSockets/Core/Middleware/WebSocketMiddleware.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; + +namespace yawaflua.WebSockets.Core.Middleware; + +public class WebSocketMiddleware : IMiddleware +{ + private readonly WebSocketRouter _router; + + public WebSocketMiddleware(WebSocketRouter router) + { + _router = router; + } + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + if (context.WebSockets.IsWebSocketRequest) + { + await _router.HandleRequest(context); + } + else + { + await next(context); + } + } +} \ No newline at end of file diff --git a/yawaflua.WebSockets/Core/WebSocket.cs b/yawaflua.WebSockets/Core/WebSocket.cs new file mode 100644 index 0000000..03f81dc --- /dev/null +++ b/yawaflua.WebSockets/Core/WebSocket.cs @@ -0,0 +1,43 @@ +using System.Net.WebSockets; +using System.Text; +using yawaflua.WebSockets.Models.Interfaces; + +namespace yawaflua.WebSockets.Core; + +public class WebSocket : IDisposable +{ + private readonly System.Net.WebSockets.WebSocket _webSocket; + private readonly WebSocketReceiveResult? _webSocketReceiveResult; + private readonly string? _message; + + public WebSocketState State => _webSocket.State; + public WebSocketCloseStatus? CloseStatus => _webSocket.CloseStatus; + public string? SubProtocol => _webSocket.SubProtocol; + public string? CloseStatusDescription => _webSocket.CloseStatusDescription; + public string? Message => _message; + public WebSocketMessageType? MessageType => _webSocketReceiveResult?.MessageType; + public IWebSocketClient Client; + internal WebSocket(System.Net.WebSockets.WebSocket webSocket, WebSocketReceiveResult? webSocketReceiveResult, string? message, IWebSocketClient client) + { + _webSocket = webSocket; + _webSocketReceiveResult = webSocketReceiveResult; + _message = message; + Client = client; + } + + public async Task SendAsync(string m, WebSocketMessageType messageType = WebSocketMessageType.Text, CancellationToken cts = default) + => await _webSocket.SendAsync( + Encoding.UTF8.GetBytes(m), + messageType, + true, + cts); + + public async Task CloseAsync(WebSocketCloseStatus closeStatus = WebSocketCloseStatus.NormalClosure, string? reason = null, CancellationToken cts = default) + => await _webSocket.CloseAsync(closeStatus, reason, cts); + + + + public void Abort() => _webSocket.Abort(); + public void Dispose() => _webSocket.Dispose(); + +} \ No newline at end of file diff --git a/yawaflua.WebSockets/Core/WebSocketManager.cs b/yawaflua.WebSockets/Core/WebSocketManager.cs new file mode 100644 index 0000000..8121ada --- /dev/null +++ b/yawaflua.WebSockets/Core/WebSocketManager.cs @@ -0,0 +1,34 @@ +using System.Linq.Expressions; +using System.Net.WebSockets; +using System.Text; +using yawaflua.WebSockets.Models.Interfaces; + +namespace yawaflua.WebSockets.Core; + +internal class WebSocketManager : IWebSocketManager +{ + public async Task Broadcast(Func selector, string message, + WebSocketMessageType messageType = WebSocketMessageType.Text, CancellationToken cts = default) + { + foreach (var client in WebSocketRouter.Clients.Where(selector)) + { + await client.webSocket.SendAsync(Encoding.UTF8.GetBytes(message), + messageType, + true, + cts); + } + } + + public List GetAllClients() + { + return WebSocketRouter.Clients; + } + + public async Task SendToUser(Guid id, string message, WebSocketMessageType messageType = WebSocketMessageType.Text, CancellationToken cts = default) + { + await WebSocketRouter.Clients.First(k => k.Id == id).webSocket.SendAsync(Encoding.UTF8.GetBytes(message), + messageType, + true, + cts); + } +} \ No newline at end of file diff --git a/yawaflua.WebSockets/Core/WebSocketRouter.cs b/yawaflua.WebSockets/Core/WebSocketRouter.cs new file mode 100644 index 0000000..70e5bcd --- /dev/null +++ b/yawaflua.WebSockets/Core/WebSocketRouter.cs @@ -0,0 +1,182 @@ +using System.Net.WebSockets; +using System.Reflection; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using yawaflua.WebSockets.Attributes; +using yawaflua.WebSockets.Models; +using yawaflua.WebSockets.Models.Abstracts; +using yawaflua.WebSockets.Models.Interfaces; + +namespace yawaflua.WebSockets.Core; + +public class WebSocketRouter +{ + internal static readonly Dictionary> Routes = new(); + internal static readonly List Clients = new(); + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public WebSocketRouter(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + this._logger = logger; + DiscoverHandlers(); + Task.Run(() => + { + Clients.ForEach(async l => + { + await l.webSocket.SendAsync(ArraySegment.Empty, WebSocketMessageType.Binary, + WebSocketMessageFlags.EndOfMessage, default); + await Task.Delay(TimeSpan.FromSeconds(10)); + }); + }); + } + + internal void DiscoverHandlers() + { + try + { + var handlerTypes = AppDomain.CurrentDomain.GetAssemblies() + .SelectMany(a => a.GetTypes()) + .Where(t => + t.IsSubclassOf(typeof(IWebSocketController)) + || t.IsSubclassOf(typeof(WebSocketController)) + || t.IsInstanceOfType(typeof(WebSocketController)) + || t.IsInstanceOfType(typeof(IWebSocketController)) + ); + using var scope = _serviceProvider.CreateScope(); + foreach (var type in handlerTypes.Where(k => k.GetMethods().Length > 0)) + { + var parentAttributeTemplate = + new PathString((type.GetCustomAttribute(typeof(WebSocketAttribute)) as WebSocketAttribute)?.Template ?? "/"); + var methods = type.GetMethods() + .Where(m => m.GetCustomAttributes(typeof(WebSocketAttribute), false).Length > 0).ToList(); + + + if (methods.Count == 0 && type.GetMethods().Any(k => k.Name.StartsWith("OnMessage"))) + { + var func = type.GetMethods() + .First(k => k.Name.StartsWith("OnMessage")); + + var parameters = func.GetParameters(); + if (parameters.Length != 2 || + parameters[0].ParameterType != typeof(WebSocket) || + parameters[1].ParameterType != typeof(HttpContext) || + func.ReturnType != typeof(Task)) + { + throw new InvalidOperationException( + $"Invalid handler signature in {type.Name}.{func.Name}"); + } + + if (func.IsStatic) + { + var delegateFunc = (Func)Delegate.CreateDelegate( + typeof(Func), + func + ); + Routes.Add(parentAttributeTemplate, delegateFunc); + } + else + { + Routes.Add(parentAttributeTemplate, async (ws, context) => + { + var instance = context.RequestServices.GetRequiredService(type); + await (Task)func.Invoke(instance, new object[] { ws, context })!; + }); + } + } + else + { + foreach (var method in methods) + { + var attribute = + (WebSocketAttribute)method.GetCustomAttributes(typeof(WebSocketAttribute), false).First(); + if (method.IsStatic) + { + var delegateFunc = (Func)Delegate.CreateDelegate( + typeof(Func), + method + ); + Routes.Add(parentAttributeTemplate+attribute.Template, delegateFunc); + } + else + { + Routes.Add(parentAttributeTemplate+attribute.Template, async (ws, context) => + { + var instance = context.RequestServices.GetRequiredService(type); + await (Task)method.Invoke(instance, new object[] { ws, context })!; + }); + } + } + } + var constructors = type.GetConstructors(); + if (constructors.Length != 0) + { + var parameters = constructors[0].GetParameters() + .Select(param => scope.ServiceProvider.GetRequiredService(param.ParameterType)) + .ToArray(); + + constructors[0].Invoke(parameters); + } + } + } + catch (Exception ex) + { + _logger.LogCritical(message:"Error when parsing attributes from assemblies: ", exception:ex); + } + } + + internal async Task HandleRequest(HttpContext context, CancellationToken cts = default) + { + try + { + if (!context.WebSockets.IsWebSocketRequest) + return; + + var path = context.Request.Path.Value; + + if (Routes.TryGetValue(path, out var handler)) + { + var webSocket = await context.WebSockets.AcceptWebSocketAsync(); + await Task.Run(async () => + { + try + { + var client = new WebSocketClient(context, webSocket, path); + Clients.Add(client); + var buffer = new byte[1024 * 4]; + while (webSocket.State == WebSocketState.Open) + { + var result = await webSocket.ReceiveAsync(new ArraySegment(buffer), cts); + if (result.MessageType != WebSocketMessageType.Close) + await handler( + new WebSocket( + webSocket, + result, + Encoding.UTF8.GetString(buffer, 0, result.Count), + client), + context); + else + Clients.Remove(client); + } + } + catch (Exception ex) + { + _logger.LogError(message:"Error with handling request: ",exception: ex); + } + + }, cts); + } + else + { + context.Response.StatusCode = 404; + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error when handle request {context.Connection.Id}: "); + } + } +} \ No newline at end of file diff --git a/yawaflua.WebSockets/Models/Abstracts/WebSocketController.cs b/yawaflua.WebSockets/Models/Abstracts/WebSocketController.cs new file mode 100644 index 0000000..04a2aa2 --- /dev/null +++ b/yawaflua.WebSockets/Models/Abstracts/WebSocketController.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Http; +using yawaflua.WebSockets.Core; +using yawaflua.WebSockets.Models.Interfaces; +using WebSocketManager = yawaflua.WebSockets.Core.WebSocketManager; + +namespace yawaflua.WebSockets.Models.Abstracts; + +public abstract class WebSocketController : IWebSocketController +{ + public IWebSocketManager WebSocketManager => new WebSocketManager(); + + public virtual Task OnMessageAsync(WebSocket webSocket, HttpContext httpContext) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/yawaflua.WebSockets/Models/Interfaces/IWebSocketClient.cs b/yawaflua.WebSockets/Models/Interfaces/IWebSocketClient.cs new file mode 100644 index 0000000..3da8e4e --- /dev/null +++ b/yawaflua.WebSockets/Models/Interfaces/IWebSocketClient.cs @@ -0,0 +1,15 @@ +using System.Net.WebSockets; +using Microsoft.AspNetCore.Http; + +namespace yawaflua.WebSockets.Models.Interfaces; + +public interface IWebSocketClient +{ + public Guid Id { get; } + public string Path { get; } + public ConnectionInfo? ConnectionInfo { get; } + public IDictionary? Items { get; set; } + public HttpRequest? HttpRequest { get; } + internal WebSocket webSocket { get; } + public Task Abort(); +} \ No newline at end of file diff --git a/yawaflua.WebSockets/Models/Interfaces/IWebSocketController.cs b/yawaflua.WebSockets/Models/Interfaces/IWebSocketController.cs new file mode 100644 index 0000000..344ca9f --- /dev/null +++ b/yawaflua.WebSockets/Models/Interfaces/IWebSocketController.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Http; +using yawaflua.WebSockets.Core; + +namespace yawaflua.WebSockets.Models.Interfaces; + +internal interface IWebSocketController +{ + Task OnMessageAsync(WebSocket webSocket, HttpContext httpContext); +} \ No newline at end of file diff --git a/yawaflua.WebSockets/Models/Interfaces/IWebSocketManager.cs b/yawaflua.WebSockets/Models/Interfaces/IWebSocketManager.cs new file mode 100644 index 0000000..9857fbf --- /dev/null +++ b/yawaflua.WebSockets/Models/Interfaces/IWebSocketManager.cs @@ -0,0 +1,11 @@ +using System.Linq.Expressions; +using System.Net.WebSockets; + +namespace yawaflua.WebSockets.Models.Interfaces; + +public interface IWebSocketManager +{ + public Task Broadcast(Func selector,string message, WebSocketMessageType messageType = WebSocketMessageType.Text, CancellationToken cts = default); + public List GetAllClients(); + public Task SendToUser(Guid id, string message, WebSocketMessageType messageType, CancellationToken cts); +} \ No newline at end of file diff --git a/yawaflua.WebSockets/Models/WebSocketClient.cs b/yawaflua.WebSockets/Models/WebSocketClient.cs new file mode 100644 index 0000000..043a8bf --- /dev/null +++ b/yawaflua.WebSockets/Models/WebSocketClient.cs @@ -0,0 +1,34 @@ +using System.Net.WebSockets; +using Microsoft.AspNetCore.Http; +using yawaflua.WebSockets.Models.Interfaces; + +namespace yawaflua.WebSockets.Models; + +internal class WebSocketClient : IWebSocketClient +{ + private HttpContext HttpContext { get; set; } + public Guid Id { get; } = Guid.NewGuid(); + public string Path { get; } + public ConnectionInfo ConnectionInfo { get => HttpContext.Connection; } + public IDictionary Items + { + get => HttpContext.Items; + set => HttpContext.Items = (value); + } + + public HttpRequest HttpRequest { get => HttpContext.Request; } + public WebSocket webSocket { get; } + + internal WebSocketClient(HttpContext httpContext, WebSocket webSocket, string path) + { + this.webSocket = webSocket; + Path = path; + this.HttpContext = httpContext; + } + + public Task Abort() + { + webSocket.Abort(); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/yawaflua.WebSockets/Properties/AssemblyInfo.cs b/yawaflua.WebSockets/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..65c50c1 --- /dev/null +++ b/yawaflua.WebSockets/Properties/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("yawaflua.WebSockets.Tests")] \ No newline at end of file diff --git a/yawaflua.WebSockets/ServiceBindings.cs b/yawaflua.WebSockets/ServiceBindings.cs new file mode 100644 index 0000000..9af0e33 --- /dev/null +++ b/yawaflua.WebSockets/ServiceBindings.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using yawaflua.WebSockets.Core; +using yawaflua.WebSockets.Core.Middleware; +using yawaflua.WebSockets.Models.Interfaces; + +namespace yawaflua.WebSockets; + +public static class ServiceBindings +{ + public static IServiceCollection SettingUpWebSockets(this IServiceCollection isc) + { + isc.AddSingleton(); + isc.AddScoped(); + isc.AddSingleton(); + return isc; + } + + public static IApplicationBuilder ConnectWebSockets(this IApplicationBuilder iab) + { + iab.UseWebSockets(); + iab.UseMiddleware(); + return iab; + } +} \ No newline at end of file diff --git a/yawaflua.WebSockets/yawaflua.WebSockets.csproj b/yawaflua.WebSockets/yawaflua.WebSockets.csproj new file mode 100644 index 0000000..d4f25f2 --- /dev/null +++ b/yawaflua.WebSockets/yawaflua.WebSockets.csproj @@ -0,0 +1,30 @@ + + + + net6.0;net7.0;net8.0;net9.0 + enable + enable + 1.0.0 + yawaflua.WebSockets + New AspNet controllers looks like websocket manager + Dmitrii Shimanskii + https://github.com/yawaflua/WebSockets + https://github.com/yawaflua/WebSockets/LICENCE + https://github.com/yawaflua/WebSockets + websocket + true + + + + + + + + + False + + + + + +