Skip to content

Server-Sent Events

Server-Sent Events (SSE) provide a simple, one-directional channel for pushing updates from the server to the browser over a standard HTTP connection. Unlike WebSockets, SSE uses plain HTTP, works through most proxies and firewalls, and supports automatic reconnection out of the box. Use SSE when you need server-to-client streaming without bidirectional communication.

  1. Add the SSE middleware to a scoped route

    const App = pidgn.Router.define(.{
    .middleware = &.{
    pidgn.errorHandler(.{}),
    pidgn.logger,
    },
    .routes = &.{
    pidgn.Router.get("/", index),
    pidgn.Router.scope("/events", .{
    .middleware = &.{pidgn.sseMiddleware(.{})},
    }, &.{
    pidgn.Router.get("", sseHandler),
    }),
    },
    });
  2. Write the SSE handler

    fn sseHandler(ctx: *pidgn.Context) !void {
    if (ctx.getAssign("sse")) |_| {
    // Client accepts text/event-stream
    ctx.respond(.ok, "text/event-stream", "event: message\ndata: Hello from SSE!\n\n");
    return;
    }
    ctx.text(.bad_request, "This endpoint requires an SSE connection.");
    }
OptionTypeDefaultDescription
extra_headers[]const [2][]const u8&.{}Additional response headers as [name, value] pairs.
pidgn.sseMiddleware(.{
.extra_headers = &.{
.{ "X-Accel-Buffering", "no" }, // Disable Nginx buffering
},
}),

When a request arrives at a route guarded by sseMiddleware:

  1. The middleware checks the Accept header for text/event-stream.
  2. If present, it sets these response headers:
    • Content-Type: text/event-stream
    • Cache-Control: no-cache
    • Connection: keep-alive
    • Plus any extra_headers from the config.
  3. It assigns "sse" = "true" to the context.
  4. The downstream handler runs and can check ctx.getAssign("sse") to confirm the SSE connection.

If the client does not send Accept: text/event-stream, the middleware still calls the downstream handler, but without setting headers or the assign. This lets you serve both SSE and regular responses from the same route if needed.

SSE follows a simple text protocol. Each event is a block of field: value lines separated by a blank line:

event: message
data: Hello, world!
event: update
data: {"count": 42}
id: 1

The standard fields are:

FieldDescription
dataThe event payload. Multiple data lines are joined with newlines.
eventThe event type name. The client listens with addEventListener(type, ...).
idSets the last event ID for reconnection.
retrySuggests a reconnection interval in milliseconds.

For persistent SSE connections where you need to send multiple events over time, use the SseWriter:

fn streamHandler(ctx: *pidgn.Context) !void {
var writer = pidgn.SseWriter.init(ctx);
try writer.sendEvent("Hello from SSE!");
try writer.sendNamedEvent("update", "{\"count\": 1}");
try writer.sendWithId("update", "{\"count\": 2}", "evt-2");
try writer.keepAlive(); // sends a comment line to prevent timeout
}
MethodDescription
sendEvent(data)Send an unnamed event with data.
sendNamedEvent(event, data)Send a named event with event type and data.
sendWithId(event, data, id)Send a named event with an id field for reconnection tracking.
keepAlive()Send a comment line (: keepalive) to prevent proxy/client timeouts.

Browsers natively support SSE via the EventSource API:

<script>
const source = new EventSource("/events");
// Listen for unnamed events
source.onmessage = (e) => {
console.log("message:", e.data);
};
// Listen for named events
source.addEventListener("update", (e) => {
const data = JSON.parse(e.data);
console.log("update:", data);
});
// Handle errors and reconnection
source.onerror = (e) => {
console.log("SSE error, will auto-reconnect");
};
</script>

EventSource automatically reconnects if the connection drops, using the last event id to resume where it left off.

A common pattern is to scope SSE routes under a prefix:

pidgn.Router.scope("/api/events", .{
.middleware = &.{
pidgn.bearerAuth(.{ .validate = &myValidator }),
pidgn.sseMiddleware(.{
.extra_headers = &.{
.{ "X-Accel-Buffering", "no" },
},
}),
},
}, &.{
pidgn.Router.get("/notifications", notificationStream),
pidgn.Router.get("/activity", activityStream),
}),
  • WebSockets — bidirectional communication when you need client-to-server messages
  • Middleware — understand the middleware pipeline and scoping
  • Context — response helpers and assigns
  • Controllers — organize your handlers
  • Response Cache — cache GET responses (not applicable to SSE, but useful for other endpoints)