Events

The server-driven event contract: declared events, generated HX-Trigger emitters, and CSRF headers.

Live demo ↗

page.ghtmx

package events

// ghtmx.CSRFHeader needs no import: generated code always imports the
// ghtmx runtime.
import "github.com/go-monolith/ghtmx/ghtmxgen"

// The server-driven event contract (FR-037): these declarations are the
// only source of the EmitItemSaved / EmitCartCleared symbols and their
// payload types in ghtmxgen.
event ItemSaved(id string, name string)

event CartCleared() // emitted alongside item-saved to demo header merging

templ eventsPage(items []Item, csrf string) {
	<!DOCTYPE html>
	<html lang="en">
		<head>
			<meta charset="utf-8"/>
			<meta name="viewport" content="width=device-width, initial-scale=1"/>
			<title>events</title>
			@ghtmxgen.HTMXScript()
			<style>
				:root { --primary: #008391; --bg: #f5f6f7; --surface: #ffffff; --text: #1c1e21; --muted: #525860; --border: #dadde1; color-scheme: light dark; }
				@media (prefers-color-scheme: dark) {
					:root { --primary: #dbbc30; --bg: #1b1b1d; --surface: #242526; --text: #e3e3e3; --muted: #a5adba; --border: #444950; }
				}
				* { box-sizing: border-box; }
				body {
					font-family: system-ui, sans-serif; margin: 0; min-height: 100vh;
					background: var(--bg); color: var(--text);
					display: flex; justify-content: center; align-items: flex-start;
				}
				.app {
					width: min(36rem, 92vw); margin: 3rem 0; padding: 1.6rem 1.8rem;
					background: var(--surface); border: 1px solid var(--border);
					border-radius: .6rem; box-shadow: 0 8px 24px rgba(0, 0, 0, .1);
				}
				.badge {
					display: inline-block; font-size: .72rem; font-weight: 600;
					color: var(--primary); border: 1px solid var(--primary);
					border-radius: 999px; padding: .1rem .6rem; margin-bottom: .8rem;
				}
				h1 { margin: 0 0 .4rem; font-size: 1.5rem; }
				.tagline { margin: 0 0 1.2rem; color: var(--muted); font-size: .95rem; }
				button, a.action {
					font-size: .9rem; padding: .45rem .9rem; border-radius: .45rem; cursor: pointer;
					border: 1px solid var(--border); background: var(--bg); color: inherit;
					text-decoration: none; display: inline-block;
				}
				button:hover, a.action:hover { border-color: var(--primary); color: var(--primary); }
				table { width: 100%; border-collapse: collapse; margin: .6rem 0 1rem; }
				td { padding: .5rem .3rem; border-bottom: 1px solid var(--border); }
				tr:last-child td { border-bottom: none; }
				td:last-child { text-align: right; }
				#board { border-radius: .45rem; }
				#board[data-last]::after {
					content: "last saved: #" attr(data-last);
					display: block; font-size: .8rem; color: var(--primary); margin-top: .6rem;
				}
				#cart {
					padding: .8rem 1rem; border: 1px dashed var(--border); border-radius: .45rem;
					color: var(--muted); font-size: .88rem;
				}
			</style>
		</head>
		<body>
			<main class="app">
				<span class="badge">ghtmx example</span>
				<h1>Events</h1>
				<p class="tagline">Saving emits two declared events through generated emitters — merged into one HX-Trigger header — and the CSRF token rides a bound hx-headers attribute.</p>
				<div id="board" hx-on:item-saved="this.dataset.last = event.detail.id">
					<table id="items">
						<tbody>
							for _, it := range items {
								<tr id={ "item-" + it.ID }>
									<td>{ it.Name }</td>
									<td>
										<button hx-post={ ghtmxgen.SaveItem(it.ID) } hx-headers={ ghtmx.CSRFHeader(csrf) } hx-target={ "#item-" + it.ID } hx-swap="outerHTML">Save</button>
									</td>
								</tr>
							}
						</tbody>
					</table>
					<div id="cart" hx-get={ eventsHome } hx-trigger="cart-cleared from:body" hx-target="#cart" hx-select="#cart" hx-swap="outerHTML">cart: refreshes itself whenever cart-cleared fires anywhere on this page</div>
				</div>
			</main>
		</body>
	</html>
}

events.go

// The events example: event declarations in page.ghtmx generate the only
// emission symbols (ghtmxgen.EmitItemSaved, ghtmxgen.EmitCartCleared) —
// an undeclared event has no symbol and cannot compile (FR-037). The save
// handler emits both events in one response; they merge into a single
// HX-Trigger header.
package events

import (
	"fmt"
	"net/http"

	"github.com/go-monolith/ghtmx/ghtmxgen"
)

// Item is a row of the demo table.
type Item struct {
	ID   string
	Name string
}

var eventItems = []Item{
	{ID: "1", Name: "Alpha"},
	{ID: "2", Name: "Beta"},
}

func eventsHome(w http.ResponseWriter, r *http.Request) {
	// The application supplies the token (from its session layer); the
	// engine only attaches it (FR-092).
	csrf := demoCSRFToken(r)
	if err := eventsPage(eventItems, csrf).Render(r.Context(), w); err != nil {
		http.Error(w, "failed to render", http.StatusInternalServerError)
	}
}

// saveItem emits two declared events in one response: the header carries
// both, merged into one correctly-serialized HX-Trigger value.
func saveItem(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	for _, it := range eventItems {
		if it.ID != id {
			continue
		}
		if err := ghtmxgen.EmitItemSaved(w, ghtmxgen.ItemSavedPayload{Id: it.ID, Name: it.Name}); err != nil {
			http.Error(w, "failed to emit", http.StatusInternalServerError)
			return
		}
		if err := ghtmxgen.EmitCartCleared(w); err != nil {
			http.Error(w, "failed to emit", http.StatusInternalServerError)
			return
		}
		fmt.Fprintf(w, `<tr id="item-%s"><td>%s (saved)</td><td></td></tr>`, it.ID, it.Name)
		return
	}
	http.NotFound(w, r)
}

// demoCSRFToken stands in for the application's real session-bound token
// source.
func demoCSRFToken(r *http.Request) string {
	if c, err := r.Cookie("csrf"); err == nil {
		return c.Value
	}
	return "demo-token"
}

// Routes builds the example's router; the official docs site mounts
// it as a live demo.
func Routes() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /events", eventsHome)
	mux.HandleFunc("POST /events/items/{id}", saveItem)
	return mux
}

cmd/main.go

// Command events serves the events example standalone.
package main

import (
	"fmt"
	"net/http"
	"os"

	example "github.com/go-monolith/ghtmx/examples/events"
)

func main() {
	addr := "127.0.0.1:8083"
	if v := os.Getenv("GHTMX_EXAMPLE_ADDR"); v != "" {
		addr = v
	}
	fmt.Printf("Listening on http://%s/events\n", addr)
	if err := http.ListenAndServe(addr, example.Routes()); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

All examples