Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions src/dotnet/skills/implementing-server-sent-events/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
---
name: implementing-server-sent-events
description: Implement Server-Sent Events (SSE) endpoints in ASP.NET Core. Use when building real-time streaming from server to client without WebSockets.
---
Comment on lines +1 to +4

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This SKILL.md is placed under src/dotnet/skills/..., but the repository layout expects skills under plugins/<plugin>/skills/<skill-name>/SKILL.md so they are packaged and discovered (e.g. plugins/dotnet/skills/implementing-server-sent-events/SKILL.md). As-is, the evaluation workflow that validates ./plugins/dotnet/skills will not discover or test this skill.

Copilot uses AI. Check for mistakes.

# Implementing Server-Sent Events (SSE) in ASP.NET Core

## When to Use

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move when to use and when not to use entirely or largely into the description, which is the thing the ai reads when deciding whether or not to load. probably shouldn't have this section here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SSE endpoints hold long-lived HTTP connections. Without connection limits, an attacker (or misbehaving client) can exhaust server resources by opening many concurrent connections. Consider adding a note about:

  • Enforcing per-client or total connection limits (e.g., via middleware or a SemaphoreSlim)
  • Requiring authentication before establishing the SSE connection
  • Setting an idle timeout to drop stale connections

This is especially relevant for a skill doc since agents will copy the pattern as-is without thinking about production hardening.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(says AI)

- Server-to-client real-time push (notifications, live updates, streaming progress)
- When you DON'T need bidirectional communication (use WebSockets for that)
- SSE is simpler than WebSockets and works over standard HTTP
- Automatic reconnection built into EventSource browser API

## When Not to Use
- Bidirectional communication needed → use WebSockets
- Binary data streaming → use WebSockets or gRPC streaming
- Need more than 6 concurrent connections per domain in HTTP/1.1 → use HTTP/2

Comment on lines +14 to +18

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This skill doc doesn’t include an explicit “Validation” section/checklist. CONTRIBUTING.md asks that changes include validation steps a reviewer can follow; consider adding a short section (e.g., how to run the sample, curl an SSE stream, verify Last-Event-ID behavior, and test via nginx) so the skill is verifiable.

Copilot uses AI. Check for mistakes.
## Inputs

| Input | Required | Description |
|-------|----------|-------------|
| Event data source | Yes | The data to stream (IAsyncEnumerable, Channel, timer, etc.) |
| Event types | No | Named event types for `event:` field |
| Client reconnection | No | Whether to support Last-Event-ID reconnection |

## Workflow

### Step 1: CRITICAL — There Is No Built-In MapSSE() or MapServerSentEvents()

ASP.NET Core has NO built-in SSE endpoint helper. You must manually write the SSE protocol.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong. We have TypedResults.ServerSentEvents
https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-10.0?view=aspnetcore-10.0#support-for-server-sent-events-sse

Most of this Skill should probably be rewritten to use TypedResults.ServerSentEvents


```csharp
// COMMON MISTAKE: Trying to use a non-existent API
// app.MapSSE("/events", ...); // DOES NOT EXIST
// app.MapServerSentEvents("/events", ...); // DOES NOT EXIST
// app.UseServerSentEvents(); // DOES NOT EXIST

// CORRECT: Use a standard minimal API endpoint with manual SSE protocol
app.MapGet("/events", async (HttpContext context, CancellationToken ct) =>
{
// CRITICAL: Set these three headers for SSE
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
Comment on lines +43 to +44

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These snippets use context.Response.Headers.ContentType / .CacheControl, but HttpResponse.Headers is an IHeaderDictionary and doesn't expose those properties. This will not compile in ASP.NET Core; set context.Response.ContentType (or Headers["Content-Type"]) and Headers["Cache-Control"] (or use context.Response.GetTypedHeaders()).

Suggested change
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
context.Response.ContentType = "text/event-stream";
context.Response.Headers["Cache-Control"] = "no-cache";

Copilot uses AI. Check for mistakes.
context.Response.Headers["Connection"] = "keep-alive";

// CRITICAL: Disable response buffering for reverse proxies (nginx, etc.)
context.Response.Headers["X-Accel-Buffering"] = "no";

await context.Response.Body.FlushAsync(ct);

// Stream events...
});
```

### Step 2: CRITICAL — SSE Protocol Format

The SSE format has strict rules. Each field ends with `\n`, and each event ends with `\n\n` (double newline).

```csharp
// CRITICAL: The SSE format is NOT just "send text"
// Each event MUST end with TWO newlines (\n\n)

// Simple data event:
await context.Response.WriteAsync($"data: {message}\n\n", ct);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should {message} be sanitized for newlines here and below. AI says - risk of injection attack

await context.Response.Body.FlushAsync(ct);

// COMMON MISTAKE: Forgetting the double newline
// await context.Response.WriteAsync($"data: {message}\n", ct); // WRONG - event never completes

// Named event with id (for reconnection):
await context.Response.WriteAsync($"id: {eventId}\n", ct);
await context.Response.WriteAsync($"event: userJoined\n", ct);
await context.Response.WriteAsync($"data: {jsonPayload}\n\n", ct);
await context.Response.Body.FlushAsync(ct);

// Multi-line data (each line needs "data: " prefix):
await context.Response.WriteAsync($"data: line 1\n", ct);
await context.Response.WriteAsync($"data: line 2\n", ct);
await context.Response.WriteAsync($"data: line 3\n\n", ct); // Only last line gets double \n
await context.Response.Body.FlushAsync(ct);
```

### Step 3: CRITICAL — Flush After Every Event

```csharp
// CRITICAL: You MUST flush after every event, otherwise the client
// won't receive anything until the buffer fills up

// Option A: Flush manually after each event
await context.Response.WriteAsync($"data: {msg}\n\n", ct);
await context.Response.Body.FlushAsync(ct); // CRITICAL

// Option B: Use StreamWriter with AutoFlush = true
await using var writer = new StreamWriter(context.Response.Body, leaveOpen: true);
writer.AutoFlush = true; // Flushes after every Write
await writer.WriteLineAsync($"data: {msg}\n"); // Note: WriteLine adds one \n, we add one more

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StreamWriter.WriteLineAsync uses the platform newline by default (often \r\n), and this example also appends an extra \n, which can produce confusing line endings for SSE. Prefer writing explicit \n (or set writer.NewLine = "\n" and use WriteAsync) to keep the SSE framing unambiguous cross-platform.

Suggested change
await writer.WriteLineAsync($"data: {msg}\n"); // Note: WriteLine adds one \n, we add one more
await writer.WriteAsync($"data: {msg}\n\n"); // Explicit \n\n for SSE event framing

Copilot uses AI. Check for mistakes.

// COMMON MISTAKE: Forgetting FlushAsync — client sees nothing
// await context.Response.WriteAsync($"data: hello\n\n", ct);
// // Missing FlushAsync! Client receives nothing until connection closes.
```

### Step 4: CRITICAL — Handle Client Disconnection with RequestAborted

```csharp
app.MapGet("/events/stream", async (HttpContext context) =>
{
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
Comment on lines +109 to +110

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same compile issue here: context.Response.Headers.ContentType / .CacheControl are not valid APIs on IHeaderDictionary. Update this snippet to use context.Response.ContentType / Headers["Cache-Control"] (or typed headers) so the example builds.

Suggested change
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
context.Response.ContentType = "text/event-stream";
context.Response.Headers["Cache-Control"] = "no-cache";

Copilot uses AI. Check for mistakes.

// CRITICAL: Use RequestAborted to detect client disconnect
var ct = context.RequestAborted;

try
{
while (!ct.IsCancellationRequested)
{
var data = await GetNextEvent(ct);
await context.Response.WriteAsync($"data: {data}\n\n", ct);
await context.Response.Body.FlushAsync(ct);
}
}
catch (OperationCanceledException)
{
// Client disconnected — this is normal, not an error
// COMMON MISTAKE: Logging this as an error or letting it propagate
}
});
```

### Step 5: Support Client Reconnection with Last-Event-ID

```csharp
app.MapGet("/events", async (HttpContext context) =>
{
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";

// CRITICAL: When EventSource reconnects, it sends Last-Event-ID header
var lastEventId = context.Request.Headers["Last-Event-ID"].FirstOrDefault();

// Set retry interval (milliseconds) — how long client waits before reconnecting
await context.Response.WriteAsync($"retry: 5000\n\n");
await context.Response.Body.FlushAsync();

var startFrom = lastEventId != null ? int.Parse(lastEventId) + 1 : 0;

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

int.Parse(lastEventId) will throw if the client sends a non-integer Last-Event-ID header (or an out-of-range value), turning a reconnect into a 500. Use int.TryParse and fall back to a safe default (or return 400) to make the reconnection example robust.

Suggested change
var startFrom = lastEventId != null ? int.Parse(lastEventId) + 1 : 0;
var startFrom = 0;
if (!string.IsNullOrEmpty(lastEventId) && int.TryParse(lastEventId, out var parsedId) && parsedId >= 0)
{
startFrom = parsedId + 1;
}

Copilot uses AI. Check for mistakes.

var ct = context.RequestAborted;
var eventId = startFrom;

try
{
while (!ct.IsCancellationRequested)
{
var data = await GetNextEvent(eventId, ct);
// CRITICAL: Send id: field so client can reconnect from this point
await context.Response.WriteAsync($"id: {eventId}\ndata: {data}\n\n", ct);
await context.Response.Body.FlushAsync(ct);
eventId++;
}
}
catch (OperationCanceledException) { }
});
```

### Step 6: Complete Implementation with IAsyncEnumerable

```csharp
app.MapGet("/events/notifications", async (
HttpContext context,
INotificationService notifications) =>
{
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
context.Response.Headers["Connection"] = "keep-alive";
Comment on lines +174 to +176

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same header API issue in this snippet: context.Response.Headers.ContentType / .CacheControl won’t compile. Please update consistently across all examples (Step 5/6) to avoid copy/paste failures.

Copilot uses AI. Check for mistakes.
context.Response.Headers["X-Accel-Buffering"] = "no";

var ct = context.RequestAborted;
var eventId = 0;

try
{
await foreach (var notification in notifications.StreamAsync(ct))
{
var json = JsonSerializer.Serialize(notification);
await context.Response.WriteAsync(
$"id: {eventId++}\nevent: {notification.Type}\ndata: {json}\n\n", ct);
await context.Response.Body.FlushAsync(ct);
}
}
catch (OperationCanceledException) { }

// CRITICAL: Don't return a value — the response is already written to
// COMMON MISTAKE: return Results.Ok() after streaming — this corrupts the response
});
```

## Common Mistakes

1. **Using a non-existent MapSSE() or MapServerSentEvents() method**: ASP.NET Core has no built-in SSE helper. You must manually set headers and write the SSE protocol format.
2. **Forgetting double newline**: Events MUST end with `\n\n`. A single `\n` means the event is not complete and the client won't process it.
3. **Not flushing**: Without `FlushAsync()` after each event, the response is buffered and the client receives nothing until disconnect.
4. **Not handling RequestAborted**: The loop runs forever if you don't check `context.RequestAborted`. `OperationCanceledException` on disconnect is normal.
5. **Returning a result after streaming**: Don't `return Results.Ok()` after writing SSE events — the response body is already being written.
6. **Missing Content-Type header**: Must be exactly `text/event-stream`, not `application/json` or `text/plain`.
7. **Missing X-Accel-Buffering: no**: Reverse proxies (nginx) buffer responses by default, breaking SSE.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth adding a note about CORS configuration. SSE endpoints are frequently consumed cross-origin via the browser EventSource API, which does not support custom headers. Without app.UseCors(...) or a CORS policy on the endpoint, cross-origin clients will silently fail. This is a common deployment surprise.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(says AI)

28 changes: 28 additions & 0 deletions src/dotnet/tests/implementing-server-sent-events/eval.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
scenarios:
- name: "Implement SSE notification endpoint in ASP.NET Core 8 minimal API"
Comment on lines +1 to +2

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This eval file is under src/dotnet/tests/..., but repo convention (and CI) expects evals at tests/<plugin>/<skill-name>/eval.yaml (e.g. tests/dotnet/implementing-server-sent-events/eval.yaml). With the current location, the evaluation workflow that runs --tests-dir ./tests/dotnet will not pick up these scenarios.

Copilot uses AI. Check for mistakes.
prompt: |
I need to implement a Server-Sent Events (SSE) endpoint in an ASP.NET Core 8 minimal API project.

The endpoint should be at GET /api/events/notifications and should:
1. Stream real-time notifications to connected clients
2. Support named event types (e.g., "message", "alert", "update")
3. Include event IDs so clients can reconnect and resume from where they left off
4. Handle client disconnection gracefully
5. Set a 5-second retry interval for client reconnection
6. Work correctly behind reverse proxies like nginx

The notification data should be JSON objects with properties: Id, Type, Message, Timestamp.

Use a simple background service that generates a notification every 2 seconds to simulate a real event source. Use Channel<T> to communicate between the background service and the SSE endpoint.

Create a complete working project with Program.cs and any needed service files.
rubric:
- "Uses manual SSE protocol (Response.WriteAsync with text/event-stream content type), NOT a non-existent MapSSE(), MapServerSentEvents(), or UseServerSentEvents() helper method"
- "Sets all required SSE headers: Content-Type text/event-stream, Cache-Control no-cache, and Connection keep-alive"
- "Uses correct SSE format with double newline (\\n\\n) to terminate each event, not single newline"
- "Calls Response.Body.FlushAsync() after each event write (or uses equivalent auto-flush mechanism) so client receives events immediately"
- "Uses HttpContext.RequestAborted or CancellationToken to detect client disconnection and handles OperationCanceledException gracefully"
- "Includes id: field in SSE events and reads Last-Event-ID header from request for reconnection support"
- "Sets retry: field to control client reconnection interval"
- "Sets X-Accel-Buffering: no header for reverse proxy compatibility"
- "Does NOT return Results.Ok() or any IResult after writing SSE events to the response body"
Loading