Building Real-Time App in .NET with REPR

WebSockets.Otp is inspired by REPR (Request-Endpoint-Response) principles and trying to bring the same elegance from Minimal APIs, FastEndpoints, or MediatR to real-time communication.

Let me show you how to build a real-time chat app in under 10 minutes.

Getting Started

Step 1: Install the Package

Code
dotnet add package WebSockets.Otp.Core

Step 2: Define Your First Endpoint

The magic of WebSockets.Otp is how naturally endpoints map to message handlers:

C#
[WsEndpoint("chat/message/send")]
public class ChatEndpoint : WsEndpoint<ChatMessage>
{
    public override async Task HandleAsync(ChatMessage message, EndpointContext context)
    {
        await context.Send
            .Group("general-chat")
            .SendAsync("chat/message/receive", message, default);
    }
}
C#
public class ChatMessage
{
    public string Username { get; set; }
    public string Message { get; set; }
}

That’s it. No base classes to wrestle with, no complex configuration. Just a class with a HandleAsync method.

Step 3: Wire It Up

In your Program.cs:

C#
builder.Services.AddWsEndpoints();

app.MapEndpoints("/ws", (opt) =>
{
    opt.OnConnected = async (context) =>
    {
        await context.Groups.AddAsync("general-chat", context.ConnectionId);
    };
    
    opt.OnDisconnected = async (context) =>
    {
        await context.Groups.RemoveAsync("general-chat", context.ConnectionId);
    };
})

Three lines of configuration. Your WebSocket server is ready.


The Client Side

Install the JavaScript client:

Code
npm i websockets.otp.js

Use it:

TypeScript
const client = useWsClient({
    url: "ws://localhost:5096/ws"
});

await client.connect();

type Message = { username: string, message: string };

// Listen for messages
const off = client.receive<Message>('chat/message/receive', (msg) => {
    console.log(`${msg.username}: ${msg.message}`);
});

// Send a message
await client.notify<Message>('chat/message/send', {
    username: "user1",
    message: "Hello, WebSockets.Otp!"
});

The client mirrors the server’s endpoint-based approach. Your message names are your routes.


Three Endpoint Patterns

WebSockets.Otp supports three patterns, each for different use cases:

1. Fire-and-Forget (No Request/Response)

Perfect for telemetry, logging, or one-way notifications:

C#
[WsEndpoint("system/status")]
public class SystemStatusEndpoint : WsEndpoint
{
    public override async Task HandleAsync(EndpointContext context)
    {
        var buffer = context.Payload.Span;
        // Process raw data if send any
    }
}

2. Request-Only

When you need to receive data but don’t need to respond directly:

C#
[WsEndpoint("notifications/subscribe")]
public class SubscribeEndpoint : WsEndpoint<SubscribeRequest>
{
    public override async Task HandleAsync(SubscribeRequest request, EndpointContext context)
    {
        await context.Groups.AddAsync("notifications", context.ConnectionId);
        
        await context.Send.SendAsync("notifications/subscribed", new
        {
            ConnectionId = context.ConnectionId,
            Data = "Subscribed successfully"
        }, default);
    }
}

3. Request/Response

When you need a direct reply — like an RPC call over WebSockets:

C#
[WsEndpoint("calculator/add")]
public class AddEndpoint : WsEndpoint<AddRequest, AddResponse>
{
    public override async Task<AddResponse> HandleAsync(AddRequest request, EndpointContext context)
    {
        return new AddResponse
        {
            Result = request.A + request.B,
            Operation = "addition"
        };
    }
}
TypeScript
    type Request = { a: number, b: number }
    type Response = {
        result: number,
        operation: string
    }

    const result = await client.send<Request, Response>('calculator/add', { a: 2, b: 2 })

    console.log(`${result.operation}: ${result.result}`)

This gives you HTTP-like request/response semantics over a persistent connection.


Real-World Features

Dependency Injection Done Right

Your endpoints are first-class citizens in the DI container:

C#
[WsEndpoint("auth/validate", ServiceLifetime.Singleton)]
public class AuthEndpoint : WsEndpoint<AuthRequest, AuthResponse>
{
    private readonly IAuthService _authService;
    private readonly ILogger<AuthEndpoint> _logger;

    public AuthEndpoint(IAuthService authService, ILogger<AuthEndpoint> logger)
    {
        _authService = authService;
        _logger = logger;
    }

    public override async Task<AuthResponse> HandleAsync(AuthRequest request, EndpointContext context)
    {
        var isValid = await _authService.ValidateAsync(request.Token);
        return new AuthResponse { Success = isValid };
    }
}

Control the lifetime per endpoint. Inject services. Log everything.

Group Management

Building a chat room, live dashboard, or collaborative tool? Groups are built in:

C#
[WsEndpoint("chat/message/send")]
public class ChatEndpoint : WsEndpoint<ChatMessage>
{
    public override async Task HandleAsync(ChatMessage request, EndpointContext context)
    {
        // Join a room
        await context.Groups.AddAsync("chat-room", context.ConnectionId);
        
        // Send to one group
        await context.Send
            .Group("chat-room")
            .SendAsync("chat/message/receive", new { Message = "Welcome!" });

        // Send to multiple groups at once
        await context.Send
            .Group("chat-room")
            .Group("announcements")
            .SendAsync("chat/message/receive", new { Message = "Important update!" });

        // Leave a room
        await context.Groups.RemoveAsync("chat-room", context.ConnectionId);
    }
}

No need to build your own connection registry. It’s handled for you.

Authorization

Apply authorization globally, per-endpoint, or both:

Global (applies to all endpoints under the path):

Code
app.MapEndpoints("/ws", (opt) =>
{
    opt.AuthorizationData =
    [
        new AuthorizeAttribute
        {
            AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme
        }
    ];
});

Per-endpoint:

C#
[Authorize(Policy = "ws.chat")]
[WsEndpoint("chat/message/send")]
public class ChatEndpoint : WsEndpoint<ChatMessage>
{
    // ...
}

Global rules for connection-wide concerns, endpoint attributes for fine-grained policies. They compose naturally.


Scaling with Redis

When you need to scale beyond a single server, WebSockets.Otp has you covered:

Code
dotnet add package WebSockets.Otp.Redis

Setup is two lines:

Code
builder.Services.AddSingleton<IConnectionMultiplexer>(
    _ => ConnectionMultiplexer.Connect("localhost:6379"));
builder.Services.AddRedisManager();

Now your connection registry is shared across instances. A message sent to a group on Server A reaches clients connected to Server B.

What’s on the Roadmap?

The library is actively evolving. Coming soon:

  • Performance optimizations

  • Client reconnection handling

  • More protocols support

  • Pre/Post processors

  • Rate limiting

  • Versioning

Links:

Have questions or feedback? Drop a comment below or open an issue on GitHub. If you find WebSockets.Otp useful, a ⭐ on GitHub!