Skip to content

Commit 8756d05

Browse files
committed
Move implementing-websocket-endpoints skill to aspnetcore plugin
Per repo restructuring feedback, ASP.NET Core specific skills should be under the aspnetcore plugin rather than the dotnet plugin.
1 parent 484d5d2 commit 8756d05

2 files changed

Lines changed: 347 additions & 0 deletions

File tree

  • plugins/aspnetcore/skills/implementing-websocket-endpoints
  • tests/aspnetcore/implementing-websocket-endpoints
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
---
2+
name: implementing-websocket-endpoints
3+
description: >
4+
Implement raw WebSocket endpoints in ASP.NET Core 8+ using the built-in middleware.
5+
USE FOR: real-time bidirectional communication (chat, live updates, gaming), WebSocket
6+
receive/send loops, AcceptWebSocketAsync, UseWebSockets middleware, connection lifecycle,
7+
broadcasting to multiple clients, WebSocket authentication.
8+
DO NOT USE FOR: server-to-client only streaming (use SSE or TypedResults.ServerSentEvents),
9+
apps needing automatic reconnection and hub abstraction (use SignalR), simple request/response (use HTTP).
10+
---
11+
12+
# Implementing WebSocket Endpoints in ASP.NET Core
13+
14+
## Inputs
15+
16+
| Input | Required | Description |
17+
|-------|----------|-------------|
18+
| WebSocket path | Yes | URL path for WebSocket endpoint |
19+
| Message format | No | Text (JSON) or binary |
20+
| Connection management | No | How to track connected clients |
21+
22+
## Workflow
23+
24+
### Step 1: CRITICAL — There is no `MapWebSocket()` method
25+
26+
```csharp
27+
// COMMON MISTAKE: Developers look for a MapWebSocket method.
28+
// It does NOT exist in ASP.NET Core.
29+
30+
// WRONG — these don't exist:
31+
app.MapWebSocket("/ws", handler); // ❌ NOT a real method
32+
app.MapGet("/ws").UseWebSocket(); // ❌ NOT a real method
33+
34+
// CORRECT — WebSocket is middleware-based, not endpoint-routing:
35+
app.UseWebSockets(); // ← Register the middleware
36+
37+
// Then handle WebSocket requests in regular middleware or endpoints:
38+
app.Map("/ws", async (HttpContext context) =>
39+
{
40+
if (!context.WebSockets.IsWebSocketRequest)
41+
{
42+
context.Response.StatusCode = StatusCodes.Status400BadRequest;
43+
return;
44+
}
45+
46+
using var ws = await context.WebSockets.AcceptWebSocketAsync();
47+
await HandleWebSocketAsync(ws, context.RequestAborted);
48+
});
49+
```
50+
51+
### Step 2: Configure WebSocket middleware
52+
53+
```csharp
54+
var app = builder.Build();
55+
56+
app.UseWebSockets(new WebSocketOptions
57+
{
58+
// CRITICAL: KeepAliveInterval sends ping frames to keep connection alive
59+
// Default is 2 minutes. Set to match your infrastructure timeouts.
60+
KeepAliveInterval = TimeSpan.FromSeconds(30),
61+
62+
// KeepAliveTimeout: how long to wait for pong response before closing
63+
// Set this to detect dead connections faster
64+
KeepAliveTimeout = TimeSpan.FromSeconds(15),
65+
66+
// Allowed origins (for browser CORS protection)
67+
// CRITICAL: Without explicit AllowedOrigins, ANY website can open a WebSocket to your API
68+
AllowedOrigins = { "https://myapp.com", "https://www.myapp.com" }
69+
});
70+
71+
// CRITICAL ORDERING: UseWebSockets MUST come before the endpoint that handles WebSockets
72+
app.UseRouting();
73+
app.UseAuthorization();
74+
// WebSocket handling endpoint comes after routing
75+
```
76+
77+
### Step 3: Implement the echo/receive loop
78+
79+
```csharp
80+
static async Task HandleWebSocketAsync(WebSocket webSocket, CancellationToken ct)
81+
{
82+
var buffer = new byte[4096];
83+
84+
// CRITICAL: The receive loop pattern
85+
// ReceiveAsync returns when a message (or close) is received
86+
var result = await webSocket.ReceiveAsync(
87+
new ArraySegment<byte>(buffer), ct);
88+
89+
while (!result.CloseStatus.HasValue)
90+
{
91+
if (result.MessageType == WebSocketMessageType.Text)
92+
{
93+
// CRITICAL: For large messages, EndOfMessage may be false
94+
// You MUST accumulate fragments until EndOfMessage == true
95+
if (!result.EndOfMessage)
96+
{
97+
// Accumulate into a MemoryStream or larger buffer
98+
// Do NOT process until EndOfMessage == true
99+
result = await webSocket.ReceiveAsync(
100+
new ArraySegment<byte>(buffer), ct);
101+
continue;
102+
}
103+
104+
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
105+
106+
// Echo back (or process the message)
107+
var responseBytes = Encoding.UTF8.GetBytes($"Echo: {message}");
108+
await webSocket.SendAsync(
109+
new ArraySegment<byte>(responseBytes),
110+
WebSocketMessageType.Text,
111+
endOfMessage: true, // ← MUST set this for the last (or only) fragment
112+
ct);
113+
}
114+
else if (result.MessageType == WebSocketMessageType.Binary)
115+
{
116+
// Handle binary messages — echo back or process as needed
117+
await webSocket.SendAsync(
118+
new ArraySegment<byte>(buffer, 0, result.Count),
119+
WebSocketMessageType.Binary,
120+
endOfMessage: result.EndOfMessage,
121+
ct);
122+
}
123+
124+
result = await webSocket.ReceiveAsync(
125+
new ArraySegment<byte>(buffer), ct);
126+
}
127+
128+
// CRITICAL: Properly close the WebSocket
129+
// You MUST respond to a close with CloseOutputAsync, NOT CloseAsync
130+
// CloseAsync = send close + wait for response (use when YOU initiate close)
131+
// CloseOutputAsync = respond to close (use when CLIENT initiated close)
132+
await webSocket.CloseOutputAsync(
133+
result.CloseStatus.Value,
134+
result.CloseStatusDescription,
135+
ct);
136+
}
137+
```
138+
139+
### Step 4: Broadcasting to multiple clients
140+
141+
```csharp
142+
// Thread-safe connection manager
143+
public class WebSocketConnectionManager
144+
{
145+
// CRITICAL: Use ConcurrentDictionary, not Dictionary
146+
// Multiple clients connect/disconnect concurrently
147+
private readonly ConcurrentDictionary<string, WebSocket> _connections = new();
148+
149+
public string AddConnection(WebSocket socket)
150+
{
151+
var id = Guid.NewGuid().ToString("N");
152+
_connections.TryAdd(id, socket);
153+
return id;
154+
}
155+
156+
public void RemoveConnection(string id)
157+
{
158+
_connections.TryRemove(id, out _);
159+
}
160+
161+
public async Task BroadcastAsync(string message, CancellationToken ct)
162+
{
163+
var bytes = Encoding.UTF8.GetBytes(message);
164+
var segment = new ArraySegment<byte>(bytes);
165+
166+
// CRITICAL: ToList() snapshot to avoid modification during iteration
167+
var tasks = _connections.Values
168+
.Where(s => s.State == WebSocketState.Open)
169+
.ToList() // ← Snapshot! Without this, concurrent disconnects cause exceptions
170+
.Select(s => s.SendAsync(segment, WebSocketMessageType.Text, true, ct));
171+
172+
// CRITICAL: Use Task.WhenAll for parallel sends
173+
// But handle individual failures — one broken connection shouldn't kill all sends
174+
try
175+
{
176+
await Task.WhenAll(tasks);
177+
}
178+
catch (Exception)
179+
{
180+
// Individual connections may have closed — clean up in the receive loop
181+
}
182+
}
183+
}
184+
185+
// Register as singleton (shared state across all requests):
186+
builder.Services.AddSingleton<WebSocketConnectionManager>();
187+
```
188+
189+
### Step 5: Authentication with WebSockets
190+
191+
```csharp
192+
// CRITICAL: The browser WebSocket API does NOT support custom HTTP headers at all.
193+
// Unlike fetch/XMLHttpRequest, you CANNOT set Authorization headers on a WebSocket connection.
194+
// Auth must happen via query string, cookies, or a pre-auth handshake.
195+
196+
// Option 1: Query string token (common for browser clients)
197+
// ⚠️ SECURITY WARNING: Tokens in query strings may leak via server/proxy logs,
198+
// browser history, and Referer headers. Always use wss:// (TLS) and consider
199+
// short-lived tokens or cookie-based auth for production.
200+
app.Map("/ws", async (HttpContext context) =>
201+
{
202+
var token = context.Request.Query["access_token"];
203+
if (string.IsNullOrEmpty(token))
204+
{
205+
context.Response.StatusCode = 401;
206+
return;
207+
}
208+
209+
// Validate token here...
210+
211+
if (context.WebSockets.IsWebSocketRequest)
212+
{
213+
using var ws = await context.WebSockets.AcceptWebSocketAsync();
214+
await HandleWebSocketAsync(ws, context.RequestAborted);
215+
}
216+
});
217+
218+
// Option 2: Cookie auth works naturally (cookies are sent on the HTTP upgrade request)
219+
// Option 3: Use [Authorize] attribute if using cookie or negotiate auth
220+
```
221+
222+
## Common Mistakes
223+
224+
1. **Looking for `MapWebSocket()`**: This method doesn't exist. WebSocket handling uses `UseWebSockets()` middleware + manual upgrade via `context.WebSockets.AcceptWebSocketAsync()`.
225+
226+
2. **Using `CloseAsync` instead of `CloseOutputAsync`**: When the client initiates close, respond with `CloseOutputAsync`. `CloseAsync` initiates a NEW close handshake (deadlock risk if both sides use it).
227+
228+
3. **Not checking `EndOfMessage`**: Large messages may arrive in fragments. Process only when `EndOfMessage == true`.
229+
230+
4. **Missing `AllowedOrigins`**: Without origin checking, any website can connect to your WebSocket endpoint (cross-site WebSocket hijacking).
231+
232+
5. **Forgetting `KeepAliveInterval`**: Load balancers and proxies close idle connections. The default 2 minutes may be too long — set to 30 seconds.
233+
234+
6. **Not handling concurrent broadcasts safely**: Use `ConcurrentDictionary` and snapshot collections (`.ToList()`) before iteration.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
scenarios:
2+
- name: "Implement WebSocket chat endpoint in ASP.NET Core 8"
3+
prompt: |
4+
I need to add a WebSocket endpoint to my ASP.NET Core 8 API for a real-time chat feature. Requirements:
5+
6+
1. WebSocket endpoint at /ws/chat
7+
2. Track connected clients and broadcast messages to all when one client sends
8+
3. Handle proper connect/disconnect lifecycle
9+
4. Authenticate users via a token in the query string (browser WebSocket API doesn't support custom headers)
10+
5. Only allow connections from our frontend at https://myapp.com
11+
12+
I've been looking for something like `app.MapWebSocket("/ws/chat", handler)` but can't find it. How does WebSocket work in ASP.NET Core 8?
13+
assertions:
14+
- type: "output_matches"
15+
pattern: "(UseWebSockets|WebSocketOptions)"
16+
- type: "output_matches"
17+
pattern: "(AcceptWebSocketAsync)"
18+
- type: "output_matches"
19+
pattern: "(ReceiveAsync|SendAsync)"
20+
- type: "output_matches"
21+
pattern: "(AllowedOrigins|Origin)"
22+
rubric:
23+
- "Explained that MapWebSocket does not exist in ASP.NET Core — WebSockets use UseWebSockets() middleware with manual upgrade via AcceptWebSocketAsync"
24+
- "Configured WebSocketOptions with KeepAliveInterval and AllowedOrigins restricted to https://myapp.com for cross-origin protection"
25+
- "Implemented a proper receive loop checking EndOfMessage for fragmented messages and CloseStatus for disconnect"
26+
- "Used CloseOutputAsync (not CloseAsync) when responding to client-initiated close to avoid deadlock"
27+
- "Implemented a thread-safe connection manager using ConcurrentDictionary for tracking and broadcasting to connected clients"
28+
- "Handled authentication via query string token since browser WebSocket API cannot send custom headers after handshake"
29+
expect_tools: ["bash"]
30+
timeout: 120
31+
32+
- name: "Fix WebSocket CloseAsync deadlock"
33+
prompt: |
34+
My ASP.NET Core WebSocket endpoint sometimes hangs when clients disconnect. The server stops responding to other WebSocket connections too. Here's my close handling code:
35+
36+
```csharp
37+
// When receive loop gets a close frame:
38+
await webSocket.CloseAsync(
39+
result.CloseStatus.Value,
40+
result.CloseStatusDescription,
41+
CancellationToken.None);
42+
```
43+
44+
What's wrong?
45+
assertions:
46+
- type: "output_matches"
47+
pattern: "(CloseOutputAsync|CloseAsync)"
48+
rubric:
49+
- "Identified the root cause: CloseAsync sends a close frame AND waits for the client's response, causing a deadlock when the client already initiated the close"
50+
- "Recommended using CloseOutputAsync instead of CloseAsync when responding to a client-initiated close"
51+
- "Explained the difference: CloseAsync = initiate close, CloseOutputAsync = respond to close"
52+
timeout: 60
53+
54+
- name: "WebSocket should not be used for server-to-client streaming"
55+
prompt: |
56+
I need to stream live stock price updates from my server to browser clients in real-time. The clients never send messages back to the server. What's the best approach in ASP.NET Core?
57+
expect_activation: false
58+
assertions:
59+
- type: "output_matches"
60+
pattern: "(SSE|Server-Sent Events|EventSource|ServerSentEvents|text/event-stream)"
61+
rubric:
62+
- "Recommended Server-Sent Events (SSE) over WebSocket for this server-to-client only use case"
63+
- "Explained why SSE is simpler: automatic reconnection, built-in browser EventSource API, no need for WebSocket connection management"
64+
- "Did NOT suggest WebSocket as the primary recommendation for this unidirectional streaming scenario"
65+
timeout: 60
66+
67+
- name: "Handle fragmented WebSocket messages correctly"
68+
prompt: |
69+
My ASP.NET Core WebSocket endpoint receives large JSON payloads (up to 1MB) from clients but sometimes the JSON parsing fails with truncated data. My receive code:
70+
71+
```csharp
72+
var buffer = new byte[4096];
73+
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
74+
var json = Encoding.UTF8.GetString(buffer, 0, result.Count);
75+
var data = JsonSerializer.Deserialize<MyData>(json);
76+
```
77+
78+
What's wrong?
79+
setup:
80+
files:
81+
- path: "WebSocketHandler.cs"
82+
content: |
83+
using System.Net.WebSockets;
84+
using System.Text;
85+
using System.Text.Json;
86+
87+
public class WebSocketHandler
88+
{
89+
public async Task HandleAsync(WebSocket ws, CancellationToken ct)
90+
{
91+
var buffer = new byte[4096];
92+
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
93+
var json = Encoding.UTF8.GetString(buffer, 0, result.Count);
94+
var data = JsonSerializer.Deserialize<Dictionary<string, object>>(json);
95+
// Process data...
96+
}
97+
}
98+
- path: "WebSocketHandler.csproj"
99+
content: |
100+
<Project Sdk="Microsoft.NET.Sdk">
101+
<PropertyGroup>
102+
<TargetFramework>net8.0</TargetFramework>
103+
</PropertyGroup>
104+
</Project>
105+
assertions:
106+
- type: "output_matches"
107+
pattern: "(EndOfMessage|endOfMessage)"
108+
rubric:
109+
- "Identified the root cause: large messages arrive in fragments and EndOfMessage is false for intermediate fragments"
110+
- "Showed how to accumulate fragments using MemoryStream or growing buffer until EndOfMessage is true"
111+
- "Only deserialize JSON after all fragments have been received"
112+
- "Suggested increasing buffer size or using a MemoryStream for accumulation"
113+
timeout: 120

0 commit comments

Comments
 (0)