Create project files

This commit is contained in:
Dmitri Shimanski
2025-03-29 02:34:08 +03:00
commit abab2be4f4
32 changed files with 1057 additions and 0 deletions

25
.dockerignore Normal file
View File

@@ -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

23
.github/workflows/dotnet.yml vendored Normal file
View File

@@ -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

48
.github/workflows/nuget.yml vendored Normal file
View File

@@ -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 }}

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/

13
.idea/.idea.yawaflua.WebSockets/.idea/.gitignore generated vendored Normal file
View File

@@ -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

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View File

@@ -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}");
}
}

21
Examples/Dockerfile Normal file
View File

@@ -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"]

22
Examples/Examples.csproj Normal file
View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\yawaflua.WebSockets\yawaflua.WebSockets.csproj" />
</ItemGroup>
</Project>

42
Examples/Program.cs Normal file
View File

@@ -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<Startup>();
})
.RunConsoleAsync();
}
}
internal class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.SettingUpWebSockets();
services.AddRouting();
services.AddHttpLogging();
services.AddSingleton<TestWebSocketServer>();
services.AddSingleton<ChatController>();
services.AddScoped<IConfiguration>(k => new ConfigurationBuilder()
.AddJsonFile("appsettings.json", true)
.Build());
}
public void Configure(IApplicationBuilder app)
{
app.ConnectWebSockets();
app.UseRouting();
app.UseHttpLogging();
app.UseWelcomePage();
}
}

View File

@@ -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));
}
}

View File

@@ -0,0 +1 @@
{}

0
LICENSE Normal file
View File

121
README.md Normal file
View File

@@ -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<ChatController>();
}
```
### 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<YourController>();
```
**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.

7
global.json Normal file
View File

@@ -0,0 +1,7 @@
{
"sdk": {
"version": "9.0.0",
"rollForward": "latestMinor",
"allowPrerelease": false
}
}

View File

@@ -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<IServiceProvider> _serviceProviderMock = new();
private readonly Mock<ILogger<WebSocketRouter>> _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<TestHandler>();
_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<System.Net.WebSockets.WebSocket>();
var contextMock = new Mock<HttpContext>();
var webSocketManagerMock = new Mock<WebSocketManager>() { 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<HttpContext>();
var responseMock = new Mock<HttpResponse>();
var webSocketManagerMock = new Mock<WebSocketManager>();
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<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(),
(Func<It.IsAnyType, Exception, string>)It.IsAny<object>()),
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<System.Net.WebSockets.WebSocket>();
webSocketMock.Setup(ws => ws.State).Returns(WebSocketState.Open);
webSocketMock.Setup(ws => ws.ReceiveAsync(It.IsAny<ArraySegment<byte>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new WebSocketReceiveResult(0, WebSocketMessageType.Close, true));
var contextMock = new Mock<HttpContext>();
var webSocketManagerMock = new Mock<WebSocketManager>();
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);
}
}

View File

@@ -0,0 +1,12 @@
using yawaflua.WebSockets;
namespace yawaflua.WebSockets.Tests;
public class ServiceBindingsTest
{
public void TestAddingInServiceProviderNewHosts()
{
}
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.0" PrivateAssets="all"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.0" PrivateAssets="all"/>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\yawaflua.WebSockets\yawaflua.WebSockets.csproj" />
</ItemGroup>
</Project>

28
yawaflua.WebSockets.sln Normal file
View File

@@ -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

View File

@@ -0,0 +1,40 @@
using JetBrains.Annotations;
using Microsoft.AspNetCore.Mvc;
namespace yawaflua.WebSockets.Attributes;
/// <summary>
/// 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
/// </summary>
/// <remarks>
/// 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).
/// </remarks>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class WebSocketAttribute : RouteAttribute
{
/// <summary>
/// Original route template specified in attribute
/// </summary>
public string Template { get; }
/// <summary>
/// Creates WebSocket route definition
/// </summary>
/// <param name="path">Route template using ASP.NET Core syntax:
/// - Static: "/status"
/// - Parameters: "/user/{id}"
/// - Constraints: "/file/{name:alpha}"
/// - Optional: "/feed/{category?}"</param>
public WebSocketAttribute([RouteTemplate]string path) : base(path)
{
Template = path;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}

View File

@@ -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<IWebSocketClient, bool> 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<IWebSocketClient> 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);
}
}

View File

@@ -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<string, Func<WebSocket, HttpContext, Task>> Routes = new();
internal static readonly List<IWebSocketClient> Clients = new();
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<WebSocketRouter> _logger;
public WebSocketRouter(IServiceProvider serviceProvider, ILogger<WebSocketRouter> logger)
{
_serviceProvider = serviceProvider;
this._logger = logger;
DiscoverHandlers();
Task.Run(() =>
{
Clients.ForEach(async l =>
{
await l.webSocket.SendAsync(ArraySegment<byte>.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<WebSocket, HttpContext, Task>)Delegate.CreateDelegate(
typeof(Func<WebSocket, HttpContext, Task>),
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<WebSocket, HttpContext, Task>)Delegate.CreateDelegate(
typeof(Func<WebSocket, HttpContext, Task>),
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<byte>(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}: ");
}
}
}

View File

@@ -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;
}
}

View File

@@ -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<object, object>? Items { get; set; }
public HttpRequest? HttpRequest { get; }
internal WebSocket webSocket { get; }
public Task Abort();
}

View File

@@ -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);
}

View File

@@ -0,0 +1,11 @@
using System.Linq.Expressions;
using System.Net.WebSockets;
namespace yawaflua.WebSockets.Models.Interfaces;
public interface IWebSocketManager
{
public Task Broadcast(Func<IWebSocketClient, bool> selector,string message, WebSocketMessageType messageType = WebSocketMessageType.Text, CancellationToken cts = default);
public List<IWebSocketClient> GetAllClients();
public Task SendToUser(Guid id, string message, WebSocketMessageType messageType, CancellationToken cts);
}

View File

@@ -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<object, object> 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;
}
}

View File

@@ -0,0 +1 @@
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("yawaflua.WebSockets.Tests")]

View File

@@ -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<WebSocketRouter>();
isc.AddScoped<IWebSocketManager, WebSocketManager>();
isc.AddSingleton<WebSocketMiddleware>();
return isc;
}
public static IApplicationBuilder ConnectWebSockets(this IApplicationBuilder iab)
{
iab.UseWebSockets();
iab.UseMiddleware<WebSocketMiddleware>();
return iab;
}
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net6.0;net7.0;net8.0;net9.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<Title>yawaflua.WebSockets</Title>
<Description>New AspNet controllers looks like websocket manager </Description>
<Copyright>Dmitrii Shimanskii</Copyright>
<PackageProjectUrl>https://github.com/yawaflua/WebSockets</PackageProjectUrl>
<PackageLicenseUrl>https://github.com/yawaflua/WebSockets/LICENCE</PackageLicenseUrl>
<RepositoryUrl>https://github.com/yawaflua/WebSockets</RepositoryUrl>
<PackageTags>websocket</PackageTags>
<PackageRequireLicenseAcceptance>true</PackageRequireLicenseAcceptance>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="JetBrains.Annotations" Version="(2023.3.0,)"/>
<PackageReference Include="Microsoft.AspNetCore.WebSockets" Version="(2.1.7,)" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="(6.0.0,)" />
<PackageReference Include="System.Net.WebSockets" Version="(4.0.0,)"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="(2.2.0,)" PrivateAssets="all">
<Private>False</Private>
</PackageReference>
<None Include="..\README.md" Pack="true" PackagePath="\"/>
<None Include="..\LICENSE" Pack="true" PackagePath=""/>
</ItemGroup>
</Project>