Gastro

SSE & Datastar

Gastro provides a lightweight SSE helper for streaming events from the server to the browser, enabling real-time UI updates with Datastar and HTMX.

There are two ways to wire SSE into a Gastro app:

  1. From a page (Track B's headline pattern). The same .gastro file renders the initial HTML on GET and emits SSE patches on POST (or any non-GET) by branching on r.Method.
  2. From a side-mounted handler registered on the router's mux. Useful for long-lived streams (live clocks, log tails) that don't share state with a particular page.

SSE-from-page (the headline)

A single pages/counter.gastro handles both the initial render and the click that increments the counter:

---
import (
    "net/http"

    "myapp/app"

    Layout "components/layout.gastro"
    Counter "components/counter.gastro"

    "github.com/andrioid/gastro/pkg/gastro/datastar"
)

state := gastro.From[*app.State](r.Context())

if r.Method == http.MethodPost {
    n := state.Count.Add(1)

    html, err := gastro.Render.Counter(CounterProps{Count: int(n)})
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    sse := datastar.NewSSE(w, r)
    sse.PatchElements(html)
    return
}

Title := "Counter"
Count := int(state.Count.Load())
---
{{ wrap Layout (dict "Title" .Title) }}
    {{ Counter (dict "Count" .Count) }}
    <button data-on:click="@post('/counter')">+1</button>
{{ end }}

What happens at runtime:

flowchart TD
    R[Incoming request] --> M{r.Method}
    M -->|GET| G[Frontmatter computes<br/>Title, Count]
    G --> GT[Template renders<br/>full page]
    M -->|POST| P[Frontmatter mutates state<br/>via state.Count.Add 1]
    P --> PR[gastro.Render.Counter<br/>→ template.HTML]
    PR --> PS[datastar.NewSSE<br/>+ PatchElements]
    PS --> PX[return — body-written flag set<br/>template render skipped]

This pattern is exercised end-to-end in examples/sse.

Required Imports

The frontmatter above pulls in the runtime alias and the net/http package. net/http is auto-imported by the codegen so you don't strictly need to declare it; app and the Datastar helper do.

Mounting

main.go becomes:

package main

import (
    "log"
    "net/http"

    gastro "myapp/.gastro"
    "myapp/app"
)

func main() {
    state := app.New()

    router := gastro.New(gastro.WithDeps(state))

    log.Fatal(http.ListenAndServe(":4242", router.Handler()))
}

There is no separate mux.HandleFunc("POST /counter", …) line. The page is the handler.

Side-mounted SSE handlers

Long-lived streams (clocks, log tails, monitoring feeds) often don't share state with a particular page. Register them on the router's mux directly:

router := gastro.New(gastro.WithDeps(state))

mux := router.Mux()
mux.HandleFunc("GET /api/clock", handleClock)

http.ListenAndServe(":4242", router.Handler())
import (
    gastroRuntime "github.com/andrioid/gastro/pkg/gastro"
)

func handleClock(w http.ResponseWriter, r *http.Request) {
    sse := gastroRuntime.NewSSE(w, r)

    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-sse.Context().Done():
            return
        case <-ticker.C:
            now := time.Now().Format("15:04:05")
            sse.Send("datastar-patch-elements",
                "elements <div id=\"clock\">"+now+"</div>")
        }
    }
}

router.Mux() returns the underlying *http.ServeMux so you can register additional routes alongside the auto-generated page handlers. Handlers registered this way bypass the deps-attachment middleware, so they cannot use gastro.From[T] — the dependency must be captured in a closure or passed via another mechanism.

Generic SSE helper

The core SSE helper in pkg/gastro is framework-agnostic and works with any client that consumes text/event-stream:

func handleUpdates(w http.ResponseWriter, r *http.Request) {
    sse := gastroRuntime.NewSSE(w, r)

    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-sse.Context().Done():
            return
        case <-ticker.C:
            sse.Send("time", time.Now().Format("15:04:05"))
        }
    }
}

Methods available:

Datastar Integration

The pkg/gastro/datastar subpackage formats events using Datastar's SSE protocol. The page-side example above uses sse.PatchElements(html). The same helper is available from side-mounted handlers:

sse := datastar.NewSSE(w, r)

sse.PatchElements(html,
    datastar.WithSelector("#dashboard"),
    datastar.WithMode(datastar.ModeInner),
)

sse.PatchSignals(map[string]any{
    "count": 42, "loading": false,
})

sse.RemoveElement("#toast-1")

Type-Safe Rendering

The compiler generates a Render API for calling Gastro components with full type safety. From frontmatter:

html, err := gastro.Render.Counter(CounterProps{Count: int(n)})

From main.go or any side-mounted handler:

import gastro "myapp/.gastro"

html, err := gastro.Render.Counter(gastro.CounterProps{Count: 42})
What Safety
Method name Compile-time — method exists or it doesn't
Props fields Compile-time — struct fields checked by Go
Props types Compile-time — Go type system

Components with children carry a Children template.HTML field on their Props struct (auto-added by codegen when the template references {{ .Children }}):

inner, _ := gastro.Render.Counter(gastro.CounterProps{Count: 42})
full, _  := gastro.Render.Layout(gastro.LayoutProps{
    Title:    "Dashboard",
    Children: template.HTML(inner),
})

Request-aware helpers in SSE patches

When your handler registers WithRequestFuncs binders (i18n, CSRF tokens, CSP nonces, ...), the package-level gastro.Render doesn't see request state — it takes the static path. To render an SSE patch with request-aware helpers in scope, use Render.With(r):

func handleUpdate(w http.ResponseWriter, r *http.Request) {
    html, _ := gastro.Render.With(r).Counter(gastro.CounterProps{Count: 42})
    datastar.NewSSE(w, r).PatchElements(html)
}

Render.With(r) is the SSE/handler counterpart to the auto-routes: both paths produce HTML with full request-aware helper resolution. The returned *renderAPI is reusable within a single request (store it in a local for multi-fragment handlers), not goroutine-safe.

Design Notes

Try the live Datastar demo →