Documentation / Realtime

Realtime

Send new HTML when something changes. Server-sent events and WebSocket rooms let your Go application keep browsers up to date.

Two ways to push HTML

HyperChi sends HTML, not data, over both transports, and the htmx 4 extensions swap it into the page.

The framework embeds the two htmx 4.0.0 extension scripts. Serve them with one call, then load them after htmx:

app := hyperchi.New()
app.Extensions().EnableExtensionAssets() // serves /hyperchi/js/hx-sse.js and hx-ws.js
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/htmx.min.js"></script>
<script src="/hyperchi/js/hx-sse.js"></script>
<script src="/hyperchi/js/hx-ws.js"></script>

Load them as plain scripts in the head. With defer, htmx processes the page before an extension registers, and the first hx-sse:connect never connects. Both extensions reconnect on their own with a growing delay, and pause while the tab is in the background.

A chat room with a WebSocket route

A socket handler serves one connection for its whole life. s.Receive() returns the next message, hyperchi.BindMessage validates it with the same form and validate tags as a form, and app.Room(room).Swap renders a template once and sends it to everyone in the room.

type ChatLine struct {
    Text string `form:"text" validate:"required,max=280"`
}

app.WebSocket("/ws/chat/{room}", func(s *hyperchi.Socket) error {
    room := s.Context().Param("room")
    // The room authorizer only checks joins a client asks for. Authorize
    // access to room in route middleware, or here, before joining.
    s.Join(room)
    for {
        msg, err := s.Receive()
        if err != nil {
            return err // ErrSocketClosed when the visitor leaves
        }
        line, err := hyperchi.BindMessage[ChatLine](msg)
        if fields := hyperchi.FieldErrors(err); fields != nil {
            if err := s.Swap("#chat-form", "outerHTML", "chat-form", hyperchi.H{"errors": fields}); err != nil {
                return err
            }
            continue
        } else if err != nil {
            return err
        }
        data := hyperchi.H{"user": s.UserID(), "text": line.Text}
        if err := app.Room(room).Swap("#feed", "beforeend", "chat-line", data); err != nil {
            return err
        }
    }
}, requireLogin)

The page connects with hx-ws:connect, and any element with hx-ws:send posts its form values to the server:

<div hx-ws:connect="/ws/chat/general">
  <ul id="feed" aria-live="polite"></ul>
  <form id="chat-form" hx-ws:send>
    <input name="text" required maxlength="280">
    <button>Send</button>
  </form>
</div>

Templates can render those attributes with escaped values: <div {{wsConnect "/ws/chat/general"}}>, {{wsSend ""}}, and {{wsClose "done"}}.

Sockets and rooms

A socket handler returns when the visitor leaves. Returning hyperchi.ErrSocketClosed or the error Receive gave ends it normally; any other error is logged and closes the socket with an internal-error status.

Call Does
s.Receive(), s.Wait() Next message; or discard messages until the client leaves, for push-only sockets.
s.Swap(target, swap, template, data) Render a template and swap it into this visitor's page only.
s.SendHTML, s.SendJSON Send trusted markup, or a data-only message for an htmx:ws:after:message:incoming listener.
s.Join(room), s.Leave(room) Room membership.
s.ID(), s.UserID() Connection ID and the server-side identity.
app.Room(name).Swap(...), .SendHTML, .SendJSON, .Reload, .Redirect Broadcast to the whole room from any handler or goroutine.

A push-only feed is a handler that joins and waits. Anything else in your program can then broadcast:

app.WebSocket("/ws/notices", func(s *hyperchi.Socket) error {
    s.Join("notices")
    return s.Wait()
})

func announce(app *hyperchi.HyperChi) error {
    return app.Room("notices").Swap("#notices", "afterbegin", "notice", hyperchi.H{"text": "Deploy at 5"})
}

The swap argument is any htmx swap style: innerHTML, beforeend, outerHTML, and so on. A route socket joins rooms from its own handler; the ?rooms= query string belongs to the generic /ws endpoint that EnableWebSocket mounts.

Identity, rooms, and origins

Three settings on the shared hub decide who may connect and what they may do. Set them before the server starts.

hub := app.Extensions().GetWebSocketHub()
hub.SetOriginPatterns([]string{"app.example.com"})
hub.SetIdentifyUser(func(r *http.Request) string {
    return security.GetUserIDFromContext(r.Context())
})
hub.SetRoomAuthorizer(func(conn *websocket.WebSocketConnection, room string) bool {
    return userMayJoin(conn.UserID, room)
})

hub.SetReceiveOnly(true) closes the generic endpoint on any client message, for pages that only listen. Route sockets read messages in your handler, so you decide there.

Server-sent events

Mount the SSE endpoint once, subscribe pages by channel name, and broadcast HTML. BroadcastSwap sends an unnamed message, which the extension swaps into the connecting element using its hx-swap.

app.Extensions().EnableSSE("/sse")

func publishCount(app *hyperchi.HyperChi, n int) error {
    html, err := app.RenderToString("visitor-count", hyperchi.H{"n": n})
    if err != nil {
        return err
    }
    app.Extensions().GetSSEHub().BroadcastSwap("stats", string(html))
    return nil
}
<div hx-sse:connect="/sse?channels=stats" hx-swap="innerHTML">
  Waiting for the first count…
</div>

Separate channels with commas to subscribe to several. The template helpers {{sseConnect "/sse?channels=stats"}}, {{sseSwap "row"}}, and {{sseClose "done"}} render the same attributes with escaping.

Named events behave differently. htmx 4 dispatches BroadcastHTML(channel, "row", html) as a DOM event and does not swap it, so the page needs a handler: {{sseSwap "row"}} swaps that event's data into the element. To end a stream, broadcast an event and name it in hx-sse:close:

hub := app.Extensions().GetSSEHub()
hub.Broadcast("jobs", sse.SSEMessage{Event: "done", Data: "done"})

Without an authorizer, a client may name any channel in the URL, which suits public broadcasts. Before a channel carries per-user data, install one: it sees the request and each requested channel, and a refusal answers 403 before the stream opens.

hub := app.Extensions().GetSSEHub()
hub.SetChannelAuthorizer(func(r *http.Request, channel string) bool {
    if channel == "news" {
        return true // public
    }
    id := security.GetUserIDFromContext(r.Context())
    return id != "" && channel == "user-"+id // signed in, and only your own
})

Live regions never get view transitions

A view transition cross-fades the whole document. On a region that updates every second, that repaints the page on each message and resets stateful components. Keep transitions out of the htmx-config meta tag, and never put transition:true in the hx-swap of an element that receives SSE or WebSocket messages. Use it only on a deliberate, low-frequency swap, such as the result of a form post.

Next steps

Watch the live server metrics, then open the WebSocket room in two tabs. The Dice Arena example is a full two-way WebSocket app, and Mailgrid streams job progress over SSE. For public hosting, limit connections and request bodies, protect form submissions from cross-origin requests, and escape all visitor text before broadcasting; the security page covers these boundaries. Directives handle the local state around a live region.