Skip to content

psdhajare/grain

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Grain

A minimal server-driven UI toolkit for Go. Build reactive web UIs with Go's html/template, server-sent events, and a tiny embedded client runtime (~3 KB gzipped). No npm, no build step, no JavaScript to write.


How it works

  1. Your server renders HTML components using html/template
  2. Browsers connect to an SSE endpoint and receive live DOM patches
  3. User clicks trigger lightweight POST /action requests — no page reloads
  4. grain.js (compiled into your binary) applies surgical DOM updates

Installation

go get github.com/psdhajare/grain

Quick start

1. Write your templates

<!-- templates/layout.html -->
{{define "layout"}}
<!DOCTYPE html>
<html>
<body>
  {{template "counter" .}}
</body>
</html>
{{end}}

<!-- templates/counter.html -->
{{define "counter"}}
<div g-id="counter">
  <p>Count: {{.Count}}</p>
  <button g-action="Increment">+</button>
  <button g-action="Decrement"></button>
  <button g-action="Reset">Reset</button>
</div>
{{end}}

No <script> tags anywhere — Grain injects them automatically. g-id must match the {{define}} name — this is how the client locates the element to patch.

2. Write the server

package main

import (
    "fmt"
    "log"
    "net/http"
    "sync"

    "github.com/psdhajare/grain"
)

type Counter struct{ Count int }

type App struct {
    mu      sync.Mutex
    counter Counter
    ch      *grain.Channel
}

func main() {
    tmpl, err := grain.ParseTemplates("templates")
    if err != nil {
        log.Fatal(err)
    }

    g  := grain.New(tmpl)
    ch := g.NewChannel()
    app := &App{ch: ch}

    mux := http.NewServeMux()

    // Page handler — ch.Page injects scripts automatically
    mux.HandleFunc("/", ch.Page("/events", func(w http.ResponseWriter, r *http.Request) {
        app.mu.Lock()
        data := app.counter
        app.mu.Unlock()
        tmpl.ExecuteTemplate(w, "layout", data)
    }))

    // Handle UI actions
    mux.HandleFunc("/action", func(w http.ResponseWriter, r *http.Request) {
        r.ParseForm()
        app.mu.Lock()
        switch r.FormValue("action") {
        case "Increment":
            app.counter.Count++
            app.ch.Push("counter", app.counter)
        case "Decrement":
            app.counter.Count--
            app.ch.Push("counter", app.counter)
        case "Reset":
            app.counter.Count = 0
            app.ch.Push("counter", app.counter)
            app.ch.Flash("info", "Counter reset")
        }
        app.mu.Unlock()
        w.WriteHeader(http.StatusNoContent)
    })

    // SSE endpoint — send current state to each browser on connect
    mux.HandleFunc("/events", ch.Handler(func(conn *grain.Conn) {
        app.mu.Lock()
        defer app.mu.Unlock()
        conn.Push("counter", app.counter)
    }))

    // Serve grain.js from memory — no static folder needed
    g.ServeJS(mux)

    fmt.Println("→ http://localhost:9000")
    log.Fatal(http.ListenAndServe(":9000", mux))
}

3. Build and run

go build -o myapp .
./myapp

Open two browser tabs — click in one and watch the other update live.


Development: hot reload

There are two kinds of changes during development and each has its own solution.

Template changes — built into Grain

HTML template changes do not require a recompile. Call g.Dev(...) after creating the Engine and Grain will re-parse your templates from disk on every render. Edit a .html file, hit the browser — the next SSE push or page load picks up the change immediately.

import "os"

g := grain.New(tmpl)

if os.Getenv("ENV") != "production" {
    g.Dev(func() (*template.Template, error) {
        return grain.ParseTemplates("templates")
    })
}

If a template has a syntax error the previous version is kept and the error is logged to stdout — the server stays up. Fix the template and the next request picks up the corrected version.

Never call Dev in production. It re-parses files from disk on every render.

Go code changes — use air

When you change .go files the binary needs to be recompiled. air watches your source files, recompiles, and restarts the server automatically. Because grain.js maintains a persistent SSE connection, it detects the restart and reconnects within 2 seconds — no manual page refresh needed.

Install air:

go install github.com/air-verse/air@latest

Add .air.toml to your project root:

root = "."
tmp_dir = "tmp"

[build]
  cmd = "go build -o tmp/main ."
  bin = "tmp/main"
  include_ext = ["go"]
  exclude_dir  = ["tmp", "vendor"]
  delay = 200

[log]
  time = false

Run during development:

air

That's it. Change any .go file → air recompiles and restarts → grain.js reconnects → page is live again. Combined with g.Dev(...), template changes reflect instantly and Go code changes reflect within ~1 second.


Example: reusable product card with Tailwind CSS

This example shows how to build a reusable component, use it in multiple places on the same page, and style everything with Tailwind — no build step, no configuration.

Project layout

shop/
├── main.go
├── go.mod
└── templates/
    ├── layout.html          ← page shell
    └── shop/
        ├── product-card.html    ← reusable card component (no g-id)
        ├── product-grid.html    ← main product grid  (g-id="product-grid")
        └── featured.html        ← featured sidebar   (g-id="featured")

grain.ParseTemplates("templates") picks up all .html files in every subfolder automatically.

1. Layout — add Tailwind CDN, no grain scripts needed

<!-- templates/layout.html -->
{{define "layout"}}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Shop</title>
  <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 text-gray-900 min-h-screen">

  <header class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
    <h1 class="text-xl font-bold tracking-tight">My Shop</h1>
  </header>

  <main class="max-w-6xl mx-auto px-6 py-8 flex gap-8">

    <!-- sidebar: featured picks -->
    <aside class="w-64 shrink-0">
      <h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 mb-4">Featured</h2>
      {{template "featured" .Featured}}
    </aside>

    <!-- main grid -->
    <div class="flex-1">
      <h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 mb-4">All products</h2>
      {{template "product-grid" .Grid}}
    </div>

  </main>

</body>
</html>
{{end}}

No <script> tags — Grain injects them automatically via ch.Page().

2. The reusable card — define once, include anywhere

product-card has no g-id. It is a pure sub-template — a building block with no Grain wiring of its own other than g-key (which the parent needs for efficient diffing) and g-action on the button.

<!-- templates/product-card.html -->
{{define "product-card"}}
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden
            hover:shadow-md transition-shadow duration-200"
     g-key="{{.ID}}">

  <img src="{{.Image}}" alt="{{.Name}}"
       class="w-full h-48 object-cover" />

  <div class="p-4">
    <h3 class="font-semibold text-gray-900 leading-snug">{{.Name}}</h3>
    <p class="text-sm text-gray-500 mt-1 line-clamp-2">{{.Description}}</p>

    <div class="mt-3 flex items-center justify-between">
      <span class="text-lg font-bold text-emerald-600">
        £{{printf "%.2f" .Price}}
      </span>

      {{if .InStock}}
        <button g-action="AddToCart" g-param="{{.ID}}"
                class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800
                       text-white text-sm font-medium px-4 py-1.5 rounded-lg
                       transition-colors">
          Add to cart
        </button>
      {{else}}
        <span class="text-sm text-gray-400 font-medium">Out of stock</span>
      {{end}}
    </div>
  </div>

</div>
{{end}}

3. The parent components — include the card with {{template}}

Each parent has a g-id and ranges over a slice, embedding product-card for every item. Both sections reuse the exact same template.

<!-- templates/product-grid.html -->
{{define "product-grid"}}
<div g-id="product-grid"
     g-transition="250"
     class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
  {{range .}}
    {{template "product-card" .}}
  {{end}}
</div>
{{end}}

<!-- templates/featured.html -->
{{define "featured"}}
<div g-id="featured"
     g-transition="250"
     class="flex flex-col gap-4">
  {{range .}}
    {{template "product-card" .}}
  {{end}}
</div>
{{end}}

4. The server

package main

import (
    "log"
    "net/http"
    "strconv"
    "strings"
    "sync"

    "github.com/psdhajare/grain"
)

type Product struct {
    ID          int
    Name        string
    Description string
    Image       string
    Price       float64
    InStock     bool
}

type PageData struct {
    Grid     []Product
    Featured []Product
}

type App struct {
    mu       sync.Mutex
    products []Product // single source of truth
    ch       *grain.Channel
}

func (a *App) featured() []Product {
    // e.g. first 3 in-stock products
    var out []Product
    for _, p := range a.products {
        if p.InStock && len(out) < 3 {
            out = append(out, p)
        }
    }
    return out
}

func main() {
    tmpl, err := grain.ParseTemplates("templates")
    if err != nil {
        log.Fatal(err)
    }

    g  := grain.New(tmpl)
    ch := g.NewChannel()

    app := &App{
        ch: ch,
        products: []Product{
            {ID: 1, Name: "Leather Wallet", Price: 39.99, InStock: true,  Description: "Full-grain leather, 6 card slots."},
            {ID: 2, Name: "Canvas Tote",    Price: 24.99, InStock: true,  Description: "Heavyweight canvas, natural colour."},
            {ID: 3, Name: "Wool Beanie",    Price: 18.99, InStock: false, Description: "100% merino wool."},
        },
    }

    mux := http.NewServeMux()

    // Page — scripts injected automatically
    mux.HandleFunc("/", ch.Page("/events", func(w http.ResponseWriter, r *http.Request) {
        app.mu.Lock()
        data := PageData{Grid: app.products, Featured: app.featured()}
        app.mu.Unlock()
        tmpl.ExecuteTemplate(w, "layout", data)
    }))

    // Actions
    mux.HandleFunc("/action", func(w http.ResponseWriter, r *http.Request) {
        r.ParseForm()
        action := r.FormValue("action")
        param  := strings.TrimSpace(r.FormValue("param"))

        app.mu.Lock()
        defer app.mu.Unlock()

        switch action {

        case "AddToCart":
            id, _ := strconv.Atoi(param)
            ch.Flash("success", "Added to cart!")
            _ = id // wire up your cart logic here

        case "MarkOutOfStock":
            // Update one product and push only that card to both sections.
            id, _ := strconv.Atoi(param)
            for i := range app.products {
                if app.products[i].ID == id {
                    app.products[i].InStock = false
                    p := app.products[i]

                    // Surgical single-card update — the other cards are untouched.
                    ch.PushItem("product-grid", strconv.Itoa(id), "product-card", p)
                    ch.PushItem("featured",     strconv.Itoa(id), "product-card", p)
                    ch.Flash("warning", p.Name + " is now out of stock")
                    break
                }
            }

        case "Restock":
            // Rebuild both sections in full (order may have changed).
            id, _ := strconv.Atoi(param)
            for i := range app.products {
                if app.products[i].ID == id {
                    app.products[i].InStock = true
                    break
                }
            }
            ch.Push("product-grid", app.products)
            ch.Push("featured", app.featured())
            ch.Flash("success", "Product restocked")
        }

        w.WriteHeader(http.StatusNoContent)
    })

    // SSE
    mux.HandleFunc("/events", ch.Handler(func(conn *grain.Conn) {
        app.mu.Lock()
        defer app.mu.Unlock()
        conn.Push("product-grid", app.products)
        conn.Push("featured", app.featured())
    }))

    g.ServeJS(mux)
    log.Fatal(http.ListenAndServe(":9000", mux))
}

What this demonstrates

The product-card template is defined once and included in both product-grid and featured with {{template "product-card" .}}. There is no duplication. Styling is pure Tailwind classes — grain.js never touches them.

When a product goes out of stock, PushItem re-renders just that one card on the server and sends a targeted DOM patch. The 59 other cards in the grid, and every card in the featured sidebar, are completely untouched — both on the server and in the browser.

When you need to add, remove, or reorder products, use Push on the parent instead. PushItem is for in-place updates to items that already exist in the DOM.

Using Tailwind CLI instead of the CDN

For production, point Tailwind's content scanner at your templates folder and it works identically to any other HTML project:

// tailwind.config.js
module.exports = {
  content: ["./templates/**/*.html"],
}

Run npx tailwindcss -o static/app.css --watch during development, then link <link rel="stylesheet" href="/static/app.css"> in your layout. Grain and Tailwind are completely independent — Grain does not care what classes are on your elements, and Tailwind does not care about g- attributes.


Template attribute reference

Attribute What it does
g-id="name" Required. Marks a component root; must match {{define "name"}}.
g-action="Action" POST /action?action=Action on click.
g-submit="Action" POST /action?action=Action on form submit (serialises all inputs).
g-param="value" Adds param=value to the action POST body.
g-signal="key: val" Declares a client-only reactive signal (no server round-trip).
g-toggle="key" Flips a boolean signal on click.
g-set="key: val" Sets a signal value on click.
g-show="key" Shows the element when the named signal is truthy.
g-hide="key" Hides the element when the named signal is truthy.
g-on="event" Fire g-action on any DOM event (e.g. input, change).
g-debounce="250" Debounce g-on by N milliseconds.
g-key="{{.ID}}" Stable identity for list items — enables keyed morphing.
g-transition Animate enter/leave on keyed list children (default 200 ms).
g-transition="300" Custom transition duration in ms.
g-error="field" Displays server validation errors for the named field.
g-scroll-end Scrolls the container to the bottom after each morph.

Server API

grain.ParseTemplates(dir string) (*template.Template, error)

Walks dir recursively and parses every .html file into a single *template.Template. Sub-directories are supported at any depth — templates are referenced by their {{define "name"}} directive, not by file path.

tmpl, err := grain.ParseTemplates("templates")
if err != nil {
    log.Fatal(err)
}
g := grain.New(tmpl)

Pass the same function to g.Dev() for template hot-reloading during development:

g.Dev(func() (*template.Template, error) {
    return grain.ParseTemplates("templates")
})

render — dynamic template inclusion

ParseTemplates automatically injects a render template function into your template set. This lets you include a template by a runtime value instead of a hard-coded name — replacing the verbose {{if eq}} chain that Go's html/template otherwise forces on you.

Without render (the old way):

<main>
  {{if eq .ContentTemplate "post-list"}}{{template "post-list" .}}{{end}}
  {{if eq .ContentTemplate "post-detail"}}{{template "post-detail" .}}{{end}}
  {{if eq .ContentTemplate "post-form"}}{{template "post-form" .}}{{end}}
  {{if eq .ContentTemplate "edit-form"}}{{template "edit-form" .}}{{end}}
</main>

With render:

<main>
  {{render .ContentTemplate .}}
</main>

A shared layout using render looks like this:

{{define "layout"}}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>{{.PageTitle}}</title>
</head>
<body>
  {{template "header" .}}
  <main>
    {{render .ContentTemplate .}}
  </main>
  {{template "footer" .}}
</body>
</html>
{{end}}

In Go, set ContentTemplate to whichever page template should be slotted in:

type PageData struct {
    PageTitle       string
    ContentTemplate string   // e.g. "post-list", "post-detail", "post-form"
    // ... page-specific fields
}

mux.HandleFunc("/", ch.Page("/events", func(w http.ResponseWriter, r *http.Request) {
    tmpl.ExecuteTemplate(w, "layout", PageData{
        PageTitle:       "My Blog",
        ContentTemplate: "post-list",
        // ...
    })
}))

mux.HandleFunc("/post/{id}", ch.Page("/events", func(w http.ResponseWriter, r *http.Request) {
    tmpl.ExecuteTemplate(w, "layout", PageData{
        PageTitle:       "Post Title",
        ContentTemplate: "post-detail",
        // ...
    })
}))

render outputs template.HTML — the output of your sub-template is already context-escaped by html/template, so wrapping it prevents a redundant second round of escaping. This is safe as long as you use html/template (which grain.ParseTemplates always does).


grain.New(tmpl) *Engine

Creates an Engine backed by your fully-parsed *template.Template. Call once at startup.

engine.NewChannel() *Channel

Creates an SSE broadcast channel. Use one per page or independent update group. Goroutine-safe.

engine.ServeJS(mux)

Registers the embedded grain.js at /grain.js. No static file folder needed — the runtime is compiled into your binary.


channel.Push(name string, data any) error

Re-renders {{define "name"}} with data and sends a DOM patch to all connected clients. Call this whenever your state changes.

ch.Push("counter", myCounter)

channel.Flash(kind, message string)

Sends a toast notification to all clients. kind is one of "success", "info", "warning", "error". The toast container is created automatically — no HTML needed.

ch.Flash("success", "Item saved!")
ch.Flash("error",   "Something went wrong")

channel.Error(target string, fields map[string]string)

Sends field-level validation errors to all clients. target is the g-id of the component; fields maps input name → error message. Errors clear automatically on the next Push of that component.

ch.Error("signup-form", map[string]string{
    "email":    "Must be a valid email address",
    "username": "Already taken",
})

In your template, add a g-error span next to each input:

{{define "signup-form"}}
<form g-id="signup-form" g-submit="Signup">
  <input type="text" name="username" />
  <span g-error="username" style="display:none; color:red; font-size:0.85em;"></span>

  <input type="email" name="email" />
  <span g-error="email" style="display:none; color:red; font-size:0.85em;"></span>

  <button type="submit">Sign up</button>
</form>
{{end}}

channel.PushItem(parent, key, itemTemplate string, data any) error

Re-renders itemTemplate with data and sends a targeted patch that updates only the element with g-key="key" inside g-id="parent". The server renders one item instead of the whole list — useful when a list has many entries and only one has changed.

// A single product's price changed — update only that card,
// leave the other 59 products in the grid untouched.
ch.PushItem("product-grid", strconv.Itoa(product.ID), "product-card", product)

The item template is a normal sub-template with no g-id of its own:

{{define "product-card"}}
<div class="card" g-key="{{.ID}}">
  <h3>{{.Name}}</h3>
  <p class="price">{{.Price | printf "£%.2f"}}</p>
  <button g-action="AddToCart" g-param="{{.ID}}">Add to cart</button>
</div>
{{end}}

{{define "product-grid"}}
<section g-id="product-grid">
  {{range .Products}}
    {{template "product-card" .}}
  {{end}}
</section>
{{end}}

If the item is not in the DOM the update is silently ignored. To add or reorder items, use Push on the parent.


channel.DeleteItem(parent, key string)

Removes the element with g-key="key" inside g-id="parent" from all connected clients. If the parent has [g-transition], the leave animation plays before removal.

// Remove a single product without re-rendering the grid.
ch.DeleteItem("product-grid", strconv.Itoa(product.ID))

channel.Page(sseEndpoint string, pageHandler http.HandlerFunc) http.HandlerFunc

Wraps a page handler and automatically injects the Grain client runtime into the HTML response just before </body>. Templates need no <script> tags.

sseEndpoint is the URL path the browser will connect to for live updates — it must match the path you register ch.Handler on.

mux.HandleFunc("/", ch.Page("/events", func(w http.ResponseWriter, r *http.Request) {
    mu.Lock()
    data := currentState
    mu.Unlock()
    tmpl.ExecuteTemplate(w, "layout", data)
}))

If the response contains no </body> tag (redirect, JSON, etc.) the page is written as-is without modification.


channel.Handler(initFn func(*Conn)) http.HandlerFunc

Returns the SSE handler to register as an endpoint. initFn is called once per new connection — use it to send the current state immediately (only to the connecting client, not broadcast).

mux.HandleFunc("/events", ch.Handler(func(conn *grain.Conn) {
    mu.Lock()
    defer mu.Unlock()
    conn.Push("my-component", currentState)
}))

conn.Push behaves like channel.Push but sends only to the one connecting client.


Multiple channels (multi-page apps)

Use one channel per page so updates only reach the relevant clients. Pass each channel's SSE endpoint to ch.Page() — the correct endpoint is injected automatically into each page's HTML:

homeChannel := g.NewChannel()
chatChannel := g.NewChannel()

mux.HandleFunc("/", homeChannel.Page("/events/home", homePageHandler))
mux.HandleFunc("/chat", chatChannel.Page("/events/chat", chatPageHandler))

mux.HandleFunc("/events/home", homeChannel.Handler(homeInitFn))
mux.HandleFunc("/events/chat", chatChannel.Handler(chatInitFn))

No <script> tags in either layout — each page automatically connects to its own SSE stream.


Project layout

Templates can be organized into any folder structure you like — grain.ParseTemplates discovers all .html files recursively. Template names come from {{define "name"}} directives, not from file paths, so sub-directories do not affect how you reference templates in Go or in other templates.

myapp/
├── main.go
├── go.mod
└── templates/
    ├── layout.html              ← {{define "layout"}}
    ├── counter/
    │   └── counter.html         ← {{define "counter"}}
    ├── shop/
    │   ├── product-card.html    ← {{define "product-card"}}
    │   └── product-grid.html    ← {{define "product-grid"}}
    └── auth/
        ├── login.html           ← {{define "login"}}
        └── signup.html          ← {{define "signup"}}

No static/ folder required — grain.js is served from memory. No <script> tags in templates — ch.Page() injects them automatically.


Security

What Grain handles automatically

Server-side template escaping is provided by Go's html/template package, which Grain requires. Every {{.Field}} interpolation is context-aware escaped before it leaves the server — HTML content, attributes, URLs, and inline JavaScript contexts are all handled correctly. A user-supplied string like <script>alert(1)</script> renders as &lt;script&gt;... in HTML context and as a JSON-encoded string inside a <script> block.

Toast and validation messages are always written via textContent, never innerHTML. User-supplied error messages and flash text cannot inject HTML regardless of what they contain.

CSS selector sanitisation — the g-id, g-key, and field name values coming through SSE events are passed through cssSafe() before being embedded in querySelector calls. A value containing " or \ cannot break out of the selector or cause a SyntaxError that would kill the event handler.

Malformed SSE payloads are caught and logged as a warning instead of throwing an uncaught exception. Without this, a single bad event would silently kill all future updates for the lifetime of the page.

X-Content-Type-Options: nosniff is set on the /grain.js response, preventing browsers from MIME-sniffing and misinterpreting the file.

What the browser guarantees

<script> tags inserted via innerHTML are never executed — this is a browser specification guarantee, not a Grain decision. The innerHTML usage in the DOM morphing path (same pattern as htmx, Turbo, Unpoly) is therefore safe against script-tag injection. Inline event handlers like onerror= on injected elements would execute, but html/template escapes attribute values including event handler contexts, making this a non-issue when templates are used correctly.

Your responsibilities as a developer

Use html/template, not text/template. The two packages have the same API but text/template does no HTML escaping. Grain will work with either, but only html/template provides XSS protection.

Never use template.HTML(userInput). The template.HTML type marks a string as pre-sanitised and bypasses all escaping. Using it with any value that derives from user input is an XSS vulnerability regardless of framework.

// ✗ dangerous — bypasses all escaping
tmpl.Execute(w, template.HTML(r.FormValue("bio")))

// ✓ safe — html/template escapes it in context
tmpl.Execute(w, r.FormValue("bio"))

Serve over HTTPS in production. SSE connections over plain HTTP can be intercepted and injected. HTTPS removes the transport-layer injection vector entirely.

Set a Content Security Policy. For a typical Grain app with no inline scripts:

Content-Security-Policy: default-src 'self'

If you use the Tailwind CDN script add script-src 'self' https://cdn.tailwindcss.com. For production, prefer the Tailwind CLI and keep script-src 'self'.


Binary size

  • Zero external dependencies (standard library only)
  • Build with CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" for a stripped binary
  • A typical Grain app compiles to ~8–10 MB; ~3–4 MB with UPX

Demo app

The example/ directory in this repo shows a full working example with a counter, live todo list (with filtering, validation, and animations), and a real-time chat board.

cd example
go run .
# → http://localhost:9000

About

Server-driven UI for Go. Reactive components over SSE — no JavaScript, no npm, no build step.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors