Skip to content

SSE (Server-Sent Events)

Provides an in-memory pub/sub broker for Server-Sent Events. Clients connect via SSE endpoints and receive real-time updates pushed from the server. Integrates naturally with htmx's SSE extension.

Package: github.com/oliverandrich/burrow/contrib/sse

Quick Start

1. Register the app

srv := burrow.NewServer(
    sse.New(),
    // ... your other apps
)

The SSE app creates a broker automatically and injects it into every request via middleware. You don't need to wire anything manually.

2. Add an SSE endpoint

In your app's Routes(), register an SSE endpoint:

func (a *App) Routes(r chi.Router) {
    r.Get("/events", sse.ContextHandler("notifications"))
}

That's it — clients connecting to /events will receive all events published to the "notifications" topic.

3. Publish events from a handler

Anywhere in a request handler, grab the broker from the context and publish:

func (a *App) CreateNote(w http.ResponseWriter, r *http.Request) error {
    note, err := a.repo.Create(r.Context(), input)
    if err != nil {
        return err
    }

    // Push update to all connected SSE clients
    sse.Broker(r.Context()).Publish("notifications", sse.Event{
        Data: fmt.Sprintf(`<li>%s</li>`, note.Title),
    })

    return burrow.Render(w, r, http.StatusOK, "notes/created", data)
}

4. Receive events in the browser

With htmx's SSE extension, no JavaScript is needed:

<div hx-ext="sse" sse-connect="/events" sse-swap="notifications">
    Waiting for updates...
</div>

When the server publishes to "notifications", htmx automatically swaps the content of this <div> with the event's Data field.

Dynamic Topics

Use ContextHandlerFunc when topics depend on the request (e.g., per-room or per-resource channels):

func (a *App) Routes(r chi.Router) {
    r.Get("/projects/{id}/events", sse.ContextHandlerFunc(func(r *http.Request) []string {
        return []string{"project:" + chi.URLParam(r, "id")}
    }))
}

Then publish to a specific project:

sse.Broker(r.Context()).Publish("project:42", sse.Event{
    Data: `<span class="badge bg-success">Updated</span>`,
})

Only clients watching project 42 receive the event.

Multiple Topics per Endpoint

A single SSE endpoint can subscribe to multiple topics:

r.Get("/events", sse.ContextHandler("notifications", "status"))
<div hx-ext="sse" sse-connect="/events">
    <div sse-swap="notifications">No notifications</div>
    <div sse-swap="status">Status: unknown</div>
</div>

Each sse-swap target only receives events matching its topic name.

Event Format

The Event struct maps to the SSE wire format:

sse.Event{
    Type:  "notifications",   // SSE "event:" field — maps to htmx sse-swap
    Data:  "<p>New item!</p>", // SSE "data:" field — multi-line handled automatically
    ID:    "42",               // SSE "id:" field (optional)
    Retry: 5000,               // SSE "retry:" field in ms (optional)
}

When publishing, if Type is empty it is automatically set to the topic name:

// Event.Type will be "alerts" — no need to set it explicitly
broker.Publish("alerts", sse.Event{Data: "fire!"})

Configuration

Flag Env TOML Default Description
sse-buffer-size SSE_BUFFER_SIZE sse.buffer_size 16 Per-client event buffer capacity

When a client's buffer is full, new events are silently dropped for that client. The publisher is never blocked.

Accessing the Broker Outside HTTP Handlers

The context-based sse.Broker(ctx) helper requires a request context with injected middleware. For background jobs, CLI commands, or other non-HTTP code, use BrokerFromRegistry instead:

func (h *NotificationJobHandler) Run(ctx context.Context) error {
    broker := sse.BrokerFromRegistry(h.registry)
    if broker == nil {
        return nil // SSE not available, skip notification
    }

    broker.Publish("notifications", sse.Event{
        Data: "<p>Background task completed!</p>",
    })
    return nil
}

BrokerFromRegistry returns nil if the SSE app is not registered or has not been configured yet — always check for nil before using the broker.

Rendering Templates in Background Jobs

In HTTP handlers, burrow.Render has access to the full request context (locale, user, CSRF token, etc.). Background jobs don't have a request, but you can still render templates using burrow.RenderFragment with a manually enriched context.

Step 1: Store the server's template executor when setting up your job handler. Since templates are built during Server.Run(), obtain the executor from an HTTP handler or a startup hook that runs after boot — not from Register or Configure:

type ArticleNotifier struct {
    exec     burrow.TemplateExecutor
    broker   *sse.EventBroker
    registry *burrow.Registry
}

func NewArticleNotifier(srv *burrow.Server, registry *burrow.Registry) *ArticleNotifier {
    return &ArticleNotifier{
        exec:     srv.TemplateExecutor(),
        broker:   sse.BrokerFromRegistry(registry),
        registry: registry,
    }
}

Step 2: In the job, build a context with the values your template needs and call RenderFragment:

func (n *ArticleNotifier) Notify(ctx context.Context, article *Article, locale string) error {
    if n.broker == nil {
        return nil
    }

    // Enrich context with template executor and locale
    ctx = burrow.WithTemplateExecutor(ctx, n.exec)
    ctx = i18n.WithLocale(ctx, locale)

    html, err := burrow.RenderFragment(ctx, "articles/list_item", map[string]any{
        "Article": article,
    })
    if err != nil {
        return err
    }

    n.broker.Publish("articles", sse.Event{Data: string(html)})
    return nil
}

Tip

Only enrich the context with values your template actually uses. SSE fragments typically need the locale for translations, but not CSRF tokens, flash messages, or navigation items — those are HTTP-only concerns.

Advanced: Explicit Broker

For cases where you need a standalone broker (e.g., in tests or with multiple brokers), you can create one explicitly and pass it to Handler / HandlerFunc directly:

broker := sse.NewEventBroker(16)
defer broker.Close()

r.Get("/events", sse.Handler(broker, "topic"))

Most applications should use ContextHandler instead — it picks up the broker from middleware automatically.

Keepalive

The handler sends a :keepalive comment every 30 seconds to prevent reverse proxies from closing idle connections.

Graceful Shutdown

On server shutdown, the broker closes all client connections. SSE handlers detect the closed connection and return, allowing the HTTP server's graceful shutdown to complete.

Interfaces Implemented

Interface Description
burrow.App Required: Name()
burrow.Configurable sse-buffer-size flag
burrow.HasMiddleware Injects broker into request context
burrow.HasShutdown Closes broker and disconnects all clients