-
Notifications
You must be signed in to change notification settings - Fork 252
Add implementing-websocket-endpoints skill #266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,234 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| --- | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: implementing-websocket-endpoints | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| description: > | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Implement raw WebSocket endpoints in ASP.NET Core 8+ using the built-in middleware. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| USE FOR: real-time bidirectional communication (chat, live updates, gaming), WebSocket | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| receive/send loops, AcceptWebSocketAsync, UseWebSockets middleware, connection lifecycle, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| broadcasting to multiple clients, WebSocket authentication. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DO NOT USE FOR: server-to-client only streaming (use SSE or TypedResults.ServerSentEvents), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| apps needing automatic reconnection and hub abstraction (use SignalR), simple request/response (use HTTP). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| --- | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Implementing WebSocket Endpoints in ASP.NET Core | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ## Inputs | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| | Input | Required | Description | | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| |-------|----------|-------------| | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| | WebSocket path | Yes | URL path for WebSocket endpoint | | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| | Message format | No | Text (JSON) or binary | | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| | Connection management | No | How to track connected clients | | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ## Workflow | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ### Step 1: CRITICAL — There is no `MapWebSocket()` method | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ```csharp | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // COMMON MISTAKE: Developers look for a MapWebSocket method. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // It does NOT exist in ASP.NET Core. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // WRONG — these don't exist: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app.MapWebSocket("/ws", handler); // ❌ NOT a real method | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app.MapGet("/ws").UseWebSocket(); // ❌ NOT a real method | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // CORRECT — WebSocket is middleware-based, not endpoint-routing: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app.UseWebSockets(); // ← Register the middleware | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Then handle WebSocket requests in regular middleware or endpoints: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app.Map("/ws", async (HttpContext context) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!context.WebSockets.IsWebSocketRequest) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| context.Response.StatusCode = StatusCodes.Status400BadRequest; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| using var ws = await context.WebSockets.AcceptWebSocketAsync(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await HandleWebSocketAsync(ws, context.RequestAborted); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ### Step 2: Configure WebSocket middleware | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ```csharp | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| var app = builder.Build(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app.UseWebSockets(new WebSocketOptions | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // CRITICAL: KeepAliveInterval sends ping frames to keep connection alive | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Default is 2 minutes. Set to match your infrastructure timeouts. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| KeepAliveInterval = TimeSpan.FromSeconds(30), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // KeepAliveTimeout: how long to wait for pong response before closing | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Set this to detect dead connections faster | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| KeepAliveTimeout = TimeSpan.FromSeconds(15), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Allowed origins (for browser CORS protection) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // CRITICAL: Without explicit AllowedOrigins, ANY website can open a WebSocket to your API | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| AllowedOrigins = { "https://myapp.com", "https://www.myapp.com" } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // CRITICAL ORDERING: UseWebSockets MUST come before the endpoint that handles WebSockets | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app.UseRouting(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app.UseAuthorization(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // WebSocket handling endpoint comes after routing | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+56
to
+74
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ### Step 3: Implement the echo/receive loop | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ```csharp | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| static async Task HandleWebSocketAsync(WebSocket webSocket, CancellationToken ct) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| var buffer = new byte[4096]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // CRITICAL: The receive loop pattern | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ReceiveAsync returns when a message (or close) is received | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| var result = await webSocket.ReceiveAsync( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| new ArraySegment<byte>(buffer), ct); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| while (!result.CloseStatus.HasValue) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (result.MessageType == WebSocketMessageType.Text) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // CRITICAL: For large messages, EndOfMessage may be false | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // You MUST accumulate fragments until EndOfMessage == true | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!result.EndOfMessage) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Accumulate into a MemoryStream or larger buffer | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Do NOT process until EndOfMessage == true | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| result = await webSocket.ReceiveAsync( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| new ArraySegment<byte>(buffer), ct); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| var message = Encoding.UTF8.GetString(buffer, 0, result.Count); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+95
to
+104
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!result.EndOfMessage) | |
| { | |
| // Accumulate into a MemoryStream or larger buffer | |
| // Do NOT process until EndOfMessage == true | |
| result = await webSocket.ReceiveAsync( | |
| new ArraySegment<byte>(buffer), ct); | |
| continue; | |
| } | |
| var message = Encoding.UTF8.GetString(buffer, 0, result.Count); | |
| using var messageBuffer = new System.IO.MemoryStream(); | |
| // Write the first fragment | |
| messageBuffer.Write(buffer, 0, result.Count); | |
| // If the message is fragmented, keep reading until EndOfMessage == true | |
| while (!result.EndOfMessage) | |
| { | |
| result = await webSocket.ReceiveAsync( | |
| new ArraySegment<byte>(buffer), ct); | |
| if (result.MessageType != WebSocketMessageType.Text) | |
| { | |
| // Unexpected change of message type; break out to avoid corrupting the buffer | |
| break; | |
| } | |
| messageBuffer.Write(buffer, 0, result.Count); | |
| } | |
| var messageBytes = messageBuffer.ToArray(); | |
| var message = Encoding.UTF8.GetString(messageBytes, 0, messageBytes.Length); |
Copilot
AI
Mar 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The receive loop has a logic error around fragmented messages. The code checks if (!result.EndOfMessage) and the comment says "Don't process partial messages!", but the block is empty and execution falls through to the SendAsync echo on lines 116–121 unconditionally — including when EndOfMessage is false. A partial message will be echoed immediately instead of being accumulated. The code should use else (or a continue/return inside the if block) so that echoing only happens after a complete message is assembled. This is especially ironic given that the comment explicitly calls this a "CRITICAL" pitfall to avoid.
| // Echo back (or process the message) | |
| var responseBytes = Encoding.UTF8.GetBytes($"Echo: {message}"); | |
| await webSocket.SendAsync( | |
| new ArraySegment<byte>(responseBytes), | |
| WebSocketMessageType.Text, | |
| endOfMessage: true, // ← MUST set this for the last (or only) fragment | |
| ct); | |
| else | |
| { | |
| // Echo back (or process the message) once the full message is received | |
| var responseBytes = Encoding.UTF8.GetBytes($"Echo: {message}"); | |
| await webSocket.SendAsync( | |
| new ArraySegment<byte>(responseBytes), | |
| WebSocketMessageType.Text, | |
| endOfMessage: true, // ← MUST set this for the last (or only) fragment | |
| ct); | |
| } |
Copilot
AI
Mar 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment on line 166 says "ToList() snapshot to avoid modification during iteration," but ToList() is never called. The LINQ chain .Where(...).Select(...) is deferred/lazy — Task.WhenAll materializes it, but only at execution time, not as a snapshot upfront. While ConcurrentDictionary.Values is a thread-safe snapshot in .NET, the comment is misleading because it promises a ToList() that isn't there. This creates a discrepancy between the stated intent and the actual code, which could mislead developers into thinking the pattern is safe by virtue of ToList() when it actually isn't being called. Either call .ToList() on the LINQ chain or update the comment to accurately reflect why this is safe without it.
| .Select(s => s.SendAsync(segment, WebSocketMessageType.Text, true, ct)); | |
| .Select(s => s.SendAsync(segment, WebSocketMessageType.Text, true, ct)) | |
| .ToList(); |
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -0,0 +1,125 @@ | ||||
| scenarios: | ||||
| - name: "Implement WebSocket chat endpoint in ASP.NET Core 8" | ||||
| prompt: | | ||||
| I need to add a WebSocket endpoint to my ASP.NET Core 8 API for a real-time chat feature. Requirements: | ||||
|
|
||||
|
Comment on lines
+1
to
+5
|
||||
| 1. WebSocket endpoint at /ws/chat | ||||
| 2. Track connected clients and broadcast messages to all when one client sends | ||||
| 3. Handle proper connect/disconnect lifecycle | ||||
| 4. Authenticate users via a token in the query string (browser WebSocket API doesn't support custom headers) | ||||
| 5. Only allow connections from our frontend at https://myapp.com | ||||
|
|
||||
| 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? | ||||
| assertions: | ||||
| - type: "output_matches" | ||||
| pattern: "(UseWebSockets|WebSocketOptions)" | ||||
| - type: "output_matches" | ||||
| pattern: "(AcceptWebSocketAsync)" | ||||
| - type: "output_matches" | ||||
| pattern: "(ReceiveAsync|SendAsync)" | ||||
| - type: "output_matches" | ||||
| pattern: "(AllowedOrigins|Origin)" | ||||
| rubric: | ||||
| - "Explained that MapWebSocket does not exist in ASP.NET Core — WebSockets use UseWebSockets() middleware with manual upgrade via AcceptWebSocketAsync" | ||||
| - "Configured WebSocketOptions with KeepAliveInterval and AllowedOrigins restricted to https://myapp.com for cross-origin protection" | ||||
| - "Implemented a proper receive loop checking EndOfMessage for fragmented messages and CloseStatus for disconnect" | ||||
| - "Used CloseOutputAsync (not CloseAsync) when responding to client-initiated close to avoid deadlock" | ||||
| - "Implemented a thread-safe connection manager using ConcurrentDictionary for tracking and broadcasting to connected clients" | ||||
| - "Handled authentication via query string token since browser WebSocket API cannot send custom headers after handshake" | ||||
| expect_tools: ["bash"] | ||||
|
||||
| expect_tools: ["bash"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The examples use
/wsas the WebSocket path (Step 1/Step 5), but the eval scenario and opening requirements describe/ws/chat. To avoid confusion and copy/paste errors, consider making the path consistent throughout the skill (either update the examples to/ws/chator make the eval prompt generic).