Skip to content

SSE & Streaming

See also: api/rest on pkg.go.dev · adapters/templ on pkg.go.dev

Runnable demos: examples/adapters-sse · examples/adapters-streaming-sse-templ

Server-Sent Events (SSE)

rest.NewSSERoute[Req, Event] registers a typed SSE route — always GET — with an event codec that validates every value before it is serialised to data: ...\n\n.

import (
    nethttp "github.com/DaniDeer/go-codex/adapters/nethttp"
    "github.com/DaniDeer/go-codex/api/rest"
    "github.com/DaniDeer/go-codex/codex"
    "github.com/DaniDeer/go-codex/validate"
)

sensorIDCodec := codex.String().Refine(validate.NonEmptyString)

// Declare the SSE route as a value.
sensorRoute, _ := rest.NewSSERoute[struct{}, SensorReading](
    "/sensors/{id}/readings",
    codex.Empty, sensorReadingCodec,
    rest.RouteMeta{OperationID: "streamSensor"},
    rest.PathParam{Name: "id", Description: "Sensor ID"}.WithCodec(sensorIDCodec),
).Register(b)

// Wire onto net/http.
nethttp.RegisterSSE(mux, sensorRoute,
    func(ctx context.Context, _ struct{}, send func(SensorReading) error) error {
        r, _ := nethttp.RequestFromContext(ctx)
        sensorID := r.PathValue("id")
        for {
            select {
            case <-ctx.Done():
                return nil  // client disconnected — context cancelled
            default:
            }
            reading := svc.Read(sensorID)
            if err := send(reading); err != nil {
                return err  // codec rejected the value — nothing written to stream
            }
            time.Sleep(time.Second)
        }
    }, nethttp.Options{Observer: obs})

Key properties: - send(event) validates via the event codec, encodes to JSON, writes data: <json>\n\n, and flushes. If the codec rejects the event, send returns an error without writing anything — the stream remains clean. - ctx.Done() signals client disconnects; handlers should return nil on context cancellation. - sensorRoute.BuildPath(vars) validates path variables before assembling the URL — same contract as RouteHandle.BuildPath. - The route appears in the OpenAPI spec as GET /sensors/{id}/readings with Content-Type: text/event-stream. - Works identically with chiadapter.SSEHandler / chiadapter.RegisterSSE; use chi.URLParam(r, "id") for path vars. - The stats observer receives RecordValidationError("response", constraint, "event") for each rejected event — use this to count codec validation failures per event type. - The stats observer receives RecordValidationError("response", constraint, "event") for each rejected event.

Chunked streaming responses

For routes that stream a response body (not SSE), use format.NewStreamed:

import (
    adapttempl "github.com/DaniDeer/go-codex/adapters/templ"
    "github.com/DaniDeer/go-codex/format"
)

// Chunked streaming HTML page (validates props before committing headers)
dashRoute, _ := rest.NewRoute[struct{}, DashboardProps]("GET", "/dashboard",
    codex.Empty, dashPropsCodec, rest.RouteMeta{},
).Register(b)
dashRoute = dashRoute.WithFormats(
    adapttempl.StreamingFormat(dashPropsCodec, dashboardPage), // chunked HTML
    format.JSON(dashPropsCodec),                               // JSON fallback
)

The adapter detects IsStreamable() == true and calls MarshalTo(props, w) — the component writes directly to ResponseWriter without buffering. Headers are committed only after validation passes.

templ SSR format plug-in

adapters/templ bridges a templ component into the existing content negotiation pipeline. Add adapttempl.Format to a route's Formats and the same handler serves HTML to browser clients and JSON to API clients — no separate route, no separate handler.

import adapttempl "github.com/DaniDeer/go-codex/adapters/templ"

// Register both formats on one route — same handler, same route.
articleRoute = articleRoute.WithFormats(
    adapttempl.Format(articlePropsCodec, ArticleCard), // Accept: text/html
    format.JSON(articlePropsCodec),                     // Accept: application/json
)

// One handler, one route — the adapter picks the format from the Accept header.
nethttp.Register(mux, articleRoute, func(ctx context.Context, _ struct{}) (ArticleProps, error) {
    return svc.GetArticle(ctx)
}, nethttp.Options{Observer: obs})

Key properties: - Props are validated via the response codec's Refine constraints before the component renders. Invalid props return HTTP 500; the template is never reached with bad data. - Works with both adapters/nethttp and adapters/chi — no adapter-specific variant needed. - adapttempl.DecodeNotSupportedError is returned by the format's Unmarshal; use errors.As to detect it. - Components written with templ.ComponentFunc require no code generation — self-contained in any .go file.

SSE with HTML fragments (HTMX-style)

Combine rest.NewSSERoute and adapttempl.Format to stream HTML fragments over SSE — the HTML-over-the-wire / HTMX sse-swap pattern:

// Each SSE event's data: field contains a rendered HTML fragment.
notifRoute, _ := rest.NewSSERoute[struct{}, NotifProps]("/sse/notifications",
    codex.Empty, notifCodec, rest.RouteMeta{},
).Register(b)
notifRoute = notifRoute.WithFormats(
    adapttempl.Format(notifCodec, notifFragment), // data: <li class="notif-warn">...</li>
)

Events with invalid props are rejected by the codec before the fragment component renders — no malformed HTML is ever sent to the client.

See also