DEV Community

RandyLahey
RandyLahey

Posted on

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

dotnet add package WebSockets.Otp.Core
Enter fullscreen mode Exit fullscreen mode

Step 2: Define Your First Endpoint

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

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

public class ChatMessage
{
    public string Username { get; set; }
    public string Message { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

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:

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);
    };
})
Enter fullscreen mode Exit fullscreen mode

Three lines of configuration. Your WebSocket server is ready.

The Client Side

Install the JavaScript client:

npm i websockets.otp.js
Enter fullscreen mode Exit fullscreen mode

Use it:

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!"
});
Enter fullscreen mode Exit fullscreen mode

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:

[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
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Request-Only

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

[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);
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Request/Response

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

[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"
        };
    }
}
Enter fullscreen mode Exit fullscreen mode
    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}`)
Enter fullscreen mode Exit fullscreen mode

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

Real-World Features

Dependency Injection

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

[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 };
    }
}
Enter fullscreen mode Exit fullscreen mode

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

Group Management

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

[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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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):

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

Per-endpoint:

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

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:

dotnet add package WebSockets.Otp.Redis
Enter fullscreen mode Exit fullscreen mode

Setup is two lines:

builder.Services.AddSingleton<IConnectionMultiplexer>(
    _ => ConnectionMultiplexer.Connect("localhost:6379"));
builder.Services.AddRedisManager();
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)