Muzak logomuzak
v0.1.10

Server-Sent Events

Router.SSE registers a route whose response is a stream rather than a body. It is the other half of what a WebSocket is usually reached for, and the simpler half: the server sends, the client listens, and a browser reads it natively with EventSource, reconnecting on its own when a stream drops.

// An event stream route is declared like any other and answers 200 rather than
// upgrading: the guards run and the input binds before a byte of the stream is
// written, and the handler owns the stream until it returns.
r.SSE("/items/stream", handlers.StreamItems,
    muzak.Summary("Follow every change to the items"),
    muzak.WithSSE(muzak.SSEOptions{
        // A stream that says nothing for long enough is closed by proxies that
        // believe it to be idle, so a comment goes out instead.
        KeepAlive: 15 * time.Second,
        // A browser reconnects on its own when a stream ends, and this is how
        // soon.
        Retry: 2 * time.Second,
    }))

The type parameter is the contract: nothing but an ItemOut can be sent, and the generated document describes the stream with that type, in the same way a handler's return type describes an ordinary response.

curl -N 'http://localhost:8080/items/stream'
retry: 2000

event: item_update
id: 1
data: {"id":"plumbus","name":"Plumbus"}

event: item_update
id: 2
data: {"id":"fleeb","name":"Fleeb"}

The retry field is sent once, before anything else, because SSEOptions.Retry was set on the route above: a client that loses the stream on the very first event already knows how long to wait. The response carries Content-Type: text/event-stream; charset=utf-8, Cache-Control: no-cache, no-transform and X-Accel-Buffering: no.

An ordinary route

Middleware runs, guards run, dependencies resolve, and the input is bound and validated before a byte of the stream is written. A request that fails any of that is answered with the usual JSON error and never becomes a stream at all.

The response header is written before the handler is called, which is what lets a client see the stream open immediately. Anything that decides whether to serve a stream at all therefore belongs in a guard or a dependency, where there is still a response to say it in.

The stream

Nothing on SSEStream takes a context, unlike WSConn, because a stream belongs to one request.

CallWhat it does
Send(data)One event carrying data, encoded as JSON into its data field
SendEvent(event)One event described in full
Comment(text)A comment line, which no client acts on and every client accepts
Context()The context governing every send
LastEventID()The identifier the client last saw
Err()The error that ended the stream, or nil while it is usable

stream.Context() is derived from the request's own and is cancelled when the client disconnects, when the request ends, or when the server begins shutting down. That makes it the one thing a handler has to watch, and every send after it reports muzak.ErrSSEStreamEnded, so a handler that only sends ends on its own.

for update := range updates {
    if err := stream.Send(update); err != nil {
        return err
    }
}

A handler that returns ErrSSEStreamEnded is treated as a stream that finished rather than one that failed, so a client going away is not logged as an error. Test for it with errors.Is; the reason the stream ended is wrapped inside.

Writes are serialized, so any number of goroutines may write to one stream and each event goes out whole.

A stream is single use. The first failure ends it, and every later send reports that same error rather than writing into a response nobody is reading. Sends that report a mistake instead, such as an event name carrying a line break, leave the stream usable.

Events with more than data

type SSEEvent[Out any] struct {
    Name    string        // the type a browser dispatches it under
    ID      string        // what a reconnect resumes from
    Retry   time.Duration // how long to wait before reconnecting
    Comment string        // text no client acts on
    Data    *Out          // the value, encoded as JSON
    Text    string        // a payload written as it stands
}
// sendChange writes one change as an event a browser can listen for by name
// and resume from by identifier.
func sendChange(stream *muzak.SSEStream[schemas.ItemOut], change core.Change) error {
    item := schemas.ItemOut{ID: change.Item.ID, Name: change.Item.Name}
    return stream.SendEvent(muzak.SSEEvent[schemas.ItemOut]{
        Name: "item_update",
        ID:   strconv.Itoa(change.Seq),
        Data: &item,
    })
}
const source = new EventSource("/items/stream")
source.addEventListener("item_update", (event) => {
    const item = JSON.parse(event.data)
    // ...
})

Data is a pointer so that an event carrying nothing can be told from one carrying a zero value, and it cannot be combined with Text. An event setting both is refused.

Name and ID may not contain a line break. An event stream is a sequence of lines, so a break in one of those fields would end it and let whatever followed be read as fields of its own, which on a stream carrying one client's input to another is event forgery. Such a send is refused rather than repaired, and the stream stays usable.

Payloads that are not JSON

Text writes a payload as it stands, for a log line or for the sentinel some completion APIs end with. A stream whose events are all text declares muzak.Empty as its model, and the generated document then says there is no schema rather than describing one that does not exist.

// StreamChat answers a prompt one token at a time, which is the shape every
// chat completion API streams in.
func StreamChat(ctx *muzak.Context, in schemas.ChatIn, stream *muzak.SSEStream[muzak.Empty]) error {
    for word := range strings.SplitSeq(in.Text, " ") {
        select {
        case <-stream.Context().Done():
            // The client closed the tab, or the server is shutting down. There
            // is no one left to answer.
            return nil
        case <-time.After(tokenDelay):
        }
        if err := stream.SendEvent(muzak.SSEEvent[muzak.Empty]{Name: "token", Text: word}); err != nil {
            return err
        }
    }
    // The sentinel some clients expect at the end of a completion. It is text
    // rather than a value, which is what Text is for.
    return stream.SendEvent(muzak.SSEEvent[muzak.Empty]{Name: "done", Text: "[DONE]"})
}

A payload spanning several lines is written as several data lines and arrives whole, which is both what the format asks for and what stops a value from ending its own field.

A stream reached by POST

A stream is not tied to GET. Router.SSEHandle registers one for any method, which is what a protocol that streams its answer to a posted document needs, and there the input binds a request body like any other route.

r.SSEHandle(http.MethodPost, "/chat/stream", handlers.StreamChat,
    muzak.Summary("Answer a prompt one token at a time"),
    muzak.WithSSE(muzak.SSEOptions{
        // A completion is never quiet for long, so the keepalive is only there
        // for the pause before the first token.
        KeepAlive: 10 * time.Second,
    }))
curl -N -X POST 'http://localhost:8080/chat/stream' \
     -H 'Content-Type: application/json' -d '{"text":"what is a plumbus"}'

Resuming a dropped stream

A browser remembers the last identifier it saw and sends it back in Last-Event-ID when it reconnects. stream.LastEventID() reads it, and that is what turns a dropped connection into a stream that picks up where it left off rather than one that starts again.

func StreamItems(ctx *muzak.Context, _ muzak.Empty, stream *muzak.SSEStream[schemas.ItemOut]) error {
    store := muzak.From[*core.ItemStore](ctx)

    // A browser sends back the identifier of the last event it saw when its
    // EventSource reconnects. The value is the client's, so one that is not a
    // number is treated as no value at all rather than as an error.
    seen := 0
    if last := stream.LastEventID(); last != "" {
        if parsed, err := strconv.Atoi(last); err == nil {
            seen = parsed
        }
    }

    // Subscribing before the backlog is read is what stops a change made in
    // between from falling through the gap between the two.
    updates := store.Watch(stream.Context())
    for _, change := range store.Since(seen) {
        if err := sendChange(stream, change); err != nil {
            return err
        }
        seen = change.Seq
    }

    for {
        select {
        case <-stream.Context().Done():
            return nil
        case change := <-updates:
            if change.Seq <= seen {
                // Already sent from the backlog above.
                continue
            }
            if err := sendChange(stream, change); err != nil {
                return err
            }
            seen = change.Seq
        }
    }
}

LastEventID is a value the client controls, so treat it as input rather than as a cursor to be trusted.

What a stream bounds

A stream costs a connection and a goroutine for as long as a client cares to hold it.

A client that......is stopped by
stops reading what it asked forwrites give up after WriteTimeout rather than pinning a goroutine and a growing socket buffer
opens streams without endMaxStreams per application, then 503 with Retry-After
opens many from one addressMaxStreamsPerIP, so one client cannot take every slot the process has
holds a stream open for hoursthe listener's own timeouts are cleared for it and replaced with a deadline per event, so a healthy stream is never cut off and an unhealthy one still is
sends a Last-Event-ID a handler echoesa name or identifier with a line break in it is refused
reads through a buffering proxyCache-Control: no-cache, no-transform, X-Accel-Buffering: no, and a keepalive comment every KeepAlive

Clearing the listener's read and write deadlines matters more than it sounds. A stream is a response that does not end, so it would otherwise die at ServerOptions.WriteTimeout however healthy it was, and the read deadline would cancel the request, and with it the stream, at ServerOptions.ReadTimeout and blame the client.

Options

FieldDefaultEffect
KeepAlive15sHow often a comment is written to a stream that has sent nothing. A negative value turns it off
WriteTimeout10sHow long one event may take to reach the client
RetryunsetThe reconnection delay advertised at the start of every stream
MaxStreams1024Streams the application serves at once. Application-level only
MaxStreamsPerIP64Streams one address holds at once. Application-level only

Set them application-wide through AppOptions.SSE and narrow them for a router or a route with WithSSE. Layering works field by field, so a route that only lengthens the keepalive keeps the application's write timeout.

MaxStreams and MaxStreamsPerIP may only be set on the application: the resource they protect is the process, not a route, so a router or a route that sets either is refused when the application is built.

The keepalive exists because a proxy that sees an idle connection for long enough closes it, and because a silent stream is indistinguishable from a dead one.

What does not apply

Compression leaves an event stream alone. Holding events in a compressor's window until something forces them out is the one thing a stream cannot survive.

The origin check a WebSocket needs has no counterpart here. An EventSource is subject to the same-origin policy and to CORS like any other request, so AppOptions.CORS already governs it. See CORS.

Nothing a handler fails with is disclosed. The response header went out before the handler ran, so a failure ends the stream and the reason goes to the log.

Shutdown

Open streams are tracked, so a graceful shutdown ends every one of them and waits for the handlers, instead of waiting out its whole deadline once per stream.

Documentation

An SSE route is documented with a 200 response carrying text/event-stream, whose schema describes the data field of a single event. OpenAPI 3.1 has no way to say "many of these, one after another", and one event's data is the only part a client has to be able to decode. A stream whose model is muzak.Empty is documented with no schema at all.

Testing

func TestStreamItems(t *testing.T) {
    client := testclient.New(t, buildApp())

    stream := client.SSE("/items/stream")

    client.Post("/items/", testclient.JSON(map[string]string{"id": "plumbus", "name": "Plumbus"})).
        AssertStatus(http.StatusCreated)

    item := stream.Decode[schemas.ItemOut]()
    if item.ID != "plumbus" {
        t.Errorf("id = %q, want plumbus", item.ID)
    }
}

muzak.SSEDial is the reading half of the same engine, so a route is tested over a real connection rather than against a second implementation:

reader, _, err := muzak.SSEDial(ctx, "http://"+app.Addr()+"/items/stream", muzak.SSEDialOptions{})
if err != nil {
    return err
}
defer reader.Close()

for {
    message, err := reader.Next(ctx)
    if errors.Is(err, muzak.ErrSSEStreamEnded) {
        return nil
    }
    if err != nil {
        return err
    }
    item, err := message.Decode[schemas.ItemOut]()
    if err != nil {
        return err
    }
    _ = item
}

A response that is not 200 with a text/event-stream body is refused rather than parsed, because a stream reader that quietly accepts an HTML error page reports "no events" for what is actually a failure. SSEDialOptions carries the method, body, headers, a LastEventID to resume from, a read limit, a read timeout and KeepComments for seeing the keepalives.

Where to go next

WebSockets covers the two-way half, and Testing covers asserting on a stream.

Open source under MIT / Apache-2.0 · sustained by the people who ship on it.Built on net/http, and nothing else.