Documentation
¶
Overview ¶
Package gohtmxelm wires a Go server to browser islands built with HTMX, Datastar, and Elm over a single versioned message contract.
The package is deliberately small: it owns the plumbing that every Go+HTMX+Elm app would otherwise reimplement — server-sent events, Datastar patches, the broker envelope, asset mounting, and the HTML conventions that connect them — and leaves application policy (routing, storage, auth, copy, catalogue loading) to the host.
The wire contract ¶
One envelope shape crosses three languages. Go constructs and validates it with Envelope; the embedded broker runtime speaks it in JavaScript; and Elm islands speak it through the BrokerPort module returned by ElmBrokerPort. All three stamp and check ProtocolVersion; a test in this package fails if the three copies disagree. Bumping ProtocolVersion is a deliberate, breaking change to the wire format.
Server-sent events ¶
Stream bundles the ResponseWriter, its Flusher, and the request context so handlers stop repeating the type-assert/set-headers/flush ritual:
s, err := gohtmxelm.NewStream(w, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.Send("update", payload)
Serve runs the common subscribe/hydrate/forward lifecycle against a Broadcaster, a thread-safe fan-out hub that slow subscribers cannot block.
Browser integration ¶
New returns a Kit configured from Options. Mount Kit.Assets under a stable prefix, render Kit.BrowserScript (and Kit.InteractionScript for the overlay convention) into your pages, and mount Elm islands with ElmIsland. BrowserScript serialises the SSE Source list the broker connects to on boot.
Localization ¶
LocaleProps is a neutral locale payload for island flags. LocalePropsFrom builds it from a host MessageCatalog, and LocalizedProps merges it with application-specific props. The library owns no catalogue loading, fallback, or pluralisation policy.
Stability ¶
The exported Go API of this package follows semantic versioning: within a major version, releases stay backwards compatible, and any breaking change requires a new major version. The on-the-wire broker format is versioned independently by ProtocolVersion; a change islands or the broker must interpret differently bumps that constant rather than the module version.
The gohtmxelm command (cmd/gohtmxelm) is a development tool — the code it scaffolds is a starting point you own, not part of this API surface. Packages under internal/ (including the simnet test harness) carry no compatibility promise. The supported Go version is the one declared in go.mod. See STABILITY.md for the full policy.
Index ¶
- Constants
- Variables
- func Assets() http.Handler
- func BrowserScript(opts Options) string
- func ElmBrokerPort() string
- func ElmContractHandler() http.Handler
- func ElmIsland(id string, module string, props any) (string, error)
- func Flush(w http.ResponseWriter) bool
- func InteractionOpenAttrs(url string, statusTarget string) string
- func InteractionResultAttrs(result string) string
- func InteractionRoot(id string) string
- func InteractionScript(opts Options) string
- func LocalizedProps(base any, locale LocaleProps) (map[string]any, error)
- func MarshalInteractionEvent(target, result string) (string, error)
- func NoContent(w http.ResponseWriter)
- func PrepareSSE(w http.ResponseWriter)
- func Serve[T any](s *Stream, b *Broadcaster[T], hydrate func(*Stream) error, ...) error
- func Trigger(w http.ResponseWriter, event string)
- func WriteDatastarPatchElements(w io.Writer, elements string) error
- func WriteDatastarPatchElementsMode(w io.Writer, mode, selector, elements string) error
- func WriteDatastarPatchSignals(w io.Writer, signals string) error
- func WriteSSE(w http.ResponseWriter, event string, data any) error
- type Broadcaster
- type Envelope
- type InteractionEvent
- type Kit
- type LocaleProps
- type MessageCatalog
- type Options
- type Source
- type Stream
- func (s *Stream) Done() <-chan struct{}
- func (s *Stream) PatchElements(elements string) error
- func (s *Stream) PatchElementsMode(mode, selector, elements string) error
- func (s *Stream) PatchSignals(signals any) error
- func (s *Stream) Ping() error
- func (s *Stream) RemoveElements(selector string) error
- func (s *Stream) Send(event string, data any) error
Examples ¶
Constants ¶
const ( // TypeReady is sent by an island once its ports are wired; the broker // replies with TypeBrokerReady. TypeReady = "READY" // TypeBrokerReady acknowledges an island and completes the handshake. TypeBrokerReady = "BROKER_READY" // TypeStateSet sets one key in shared broker state and routes onward. TypeStateSet = "STATE_SET" // TypeStatePatch merges an object into shared broker state. TypeStatePatch = "STATE_PATCH" // TypeSend delivers an opaque payload to a target without touching state. TypeSend = "SEND" // TypeHTMXSwap asks the broker to run an htmx.ajax swap on a selector. TypeHTMXSwap = "HTMX_SWAP" // TypeSSEEvent carries one forwarded Server-Sent Event: payload is // {event, data}. This is how all server-pushed application state reaches // islands generically. TypeSSEEvent = "SSE_EVENT" // TypeHTMXAfterSwap is broadcast to islands after an htmx swap settles so // they can react to server-rendered fragment changes; payload is // {targetId, url}. TypeHTMXAfterSwap = "HTMX_AFTER_SWAP" )
Broker message types. These are the only types the generic broker understands. Application-specific events travel as the payload of a generic type (typically SSE_EVENT), so adding a new application event never requires touching the broker.
const ( TargetBroker = "broker" // handled internally TargetBroadcast = "broadcast" // every island TargetOthers = "others" // every island except the sender )
Routing targets understood by the broker's router.
const ProtocolVersion = 1
ProtocolVersion is the broker envelope version. The browser broker and every Elm island stamp and validate this exact value; bumping it is a deliberate, breaking change to the wire contract.
Variables ¶
var ErrStreamingUnsupported = errors.New("gohtmxelm: streaming unsupported")
ErrStreamingUnsupported is returned by NewStream when the ResponseWriter cannot flush, which means SSE is impossible on that connection.
Functions ¶
func Assets ¶
Assets returns an HTTP handler for the embedded gohtmxelm browser runtime. Mount it under a stable prefix in the host application, for example:
mux.Handle("/gohtmxelm/", http.StripPrefix("/gohtmxelm/", gohtmxelm.Assets()))
func BrowserScript ¶
BrowserScript returns a <script> tag that loads the embedded broker runtime and configures it from the supplied Options. SSE sources are serialised into a single data-sources attribute the broker reads on boot.
func ElmBrokerPort ¶
func ElmBrokerPort() string
ElmBrokerPort returns the canonical BrokerPort.elm source. Write it into your project's Elm source directory (the module name is "BrokerPort") so islands can `import BrokerPort`. The returned source matches ProtocolVersion, so re-vendoring after a library upgrade is how you stay in sync with the broker.
Example ¶
Vendor the canonical Elm-side contract into a project's Elm source directory.
package main
import (
"fmt"
"strings"
"github.com/nkhine/gohtmxelm"
)
func main() {
src := gohtmxelm.ElmBrokerPort()
fmt.Println(strings.SplitN(src, "\n", 2)[0])
}
Output: port module BrokerPort exposing
func ElmContractHandler ¶
ElmContractHandler serves the embedded BrokerPort.elm source as plain text. It is handy for a `gohtmxelm doctor`-style endpoint or for tooling that fetches the contract over HTTP; most projects vendor ElmBrokerPort() at build time instead.
func ElmIsland ¶
ElmIsland returns the HTML mount point convention used by the broker. The returned string is fully escaped; in templ wrap it with templ.Raw, and with html/template use template.HTML.
Example ¶
Render an Elm island mount point. The broker looks the module up on window.Elm by name and hydrates it with the JSON-encoded props.
package main
import (
"fmt"
"github.com/nkhine/gohtmxelm"
)
func main() {
html, err := gohtmxelm.ElmIsland("cart", "AppA", map[string]any{"count": 3})
if err != nil {
panic(err)
}
fmt.Println(html)
}
Output: <div class="elm-island" id="cart" data-elm-module="AppA" data-island-id="cart" data-props="{"count":3}"></div>
func Flush ¶
func Flush(w http.ResponseWriter) bool
Flush flushes a streaming response when the server supports it.
func InteractionOpenAttrs ¶
InteractionOpenAttrs serialises the common attributes for a button or link that opens a server-rendered interaction fragment.
Example ¶
Serialise the attributes for a button that opens a server-rendered interaction fragment and reports its result into a status element.
package main
import (
"fmt"
"github.com/nkhine/gohtmxelm"
)
func main() {
fmt.Printf("<button%s>Delete</button>\n",
gohtmxelm.InteractionOpenAttrs("/api/interactions/confirm", "#status"))
}
Output: <button data-gohtmxelm-open="/api/interactions/confirm" data-gohtmxelm-status="#status">Delete</button>
func InteractionResultAttrs ¶
InteractionResultAttrs serialises the attributes for an element that closes the nearest interaction fragment with a result value.
func InteractionRoot ¶
InteractionRoot returns the shared overlay root used by the browser interaction runtime. Server-rendered fragments can be appended here via data-gohtmxelm-open or GoHTMXElmInteractions.open().
func InteractionScript ¶
InteractionScript returns a <script> tag for the embedded interaction runtime. Mount Assets under the same AssetPath used for BrowserScript.
func LocalizedProps ¶
func LocalizedProps(base any, locale LocaleProps) (map[string]any, error)
LocalizedProps merges application-specific props with the standard locale payload. The returned map is suitable for ElmIsland's props argument.
The base value must encode as a JSON object (struct or map). Locale fields win on key collisions because callers use this helper when they want a canonical localization payload.
Example ¶
Merge application props with a standard locale payload for an Elm island.
package main
import (
"fmt"
"github.com/nkhine/gohtmxelm"
)
func main() {
props, err := gohtmxelm.LocalizedProps(
map[string]any{"count": 3},
gohtmxelm.LocaleProps{Locale: "en-GB", Currency: "GBP"},
)
if err != nil {
panic(err)
}
fmt.Println(props)
}
Output: map[count:3 currency:GBP locale:en-GB]
func MarshalInteractionEvent ¶
MarshalInteractionEvent is a small testable helper for hosts that want to emit the same event shape from custom JavaScript or SSE payloads.
func NoContent ¶
func NoContent(w http.ResponseWriter)
NoContent writes a normal empty HTMX response.
func PrepareSSE ¶
func PrepareSSE(w http.ResponseWriter)
PrepareSSE sets the standard headers for a long-lived SSE response.
func Serve ¶
func Serve[T any](s *Stream, b *Broadcaster[T], hydrate func(*Stream) error, each func(*Stream, T) error) error
Serve runs the common SSE lifecycle against a Broadcaster: it subscribes, invokes hydrate once for the initial snapshot, then forwards every published value through each until the client disconnects. Unsubscribe is automatic. A nil hydrate or each is skipped. If hydrate or each returns an error the stream ends.
func Trigger ¶
func Trigger(w http.ResponseWriter, event string)
Trigger sets HX-Trigger so HTMX clients can react to a server-side event.
func WriteDatastarPatchElements ¶
WriteDatastarPatchElements writes a Datastar element patch event to an SSE response. The supplied HTML should include stable ids for Datastar targets.
func WriteDatastarPatchElementsMode ¶
WriteDatastarPatchElementsMode writes a Datastar element patch with an explicit mode (e.g. "prepend", "append", "remove") and optional CSS selector. Empty mode/selector fall back to Datastar's defaults (morph the element by id). Empty elements is valid for the "remove" mode.
func WriteDatastarPatchSignals ¶
WriteDatastarPatchSignals writes a Datastar signal patch event to an SSE response. The signals value must be a JSON object string.
Example ¶
Write a Datastar signal patch to an SSE response.
package main
import (
"os"
"github.com/nkhine/gohtmxelm"
)
func main() {
_ = gohtmxelm.WriteDatastarPatchSignals(os.Stdout, `{"count":3}`)
}
Output: event: datastar-patch-signals data: signals {"count":3}
Types ¶
type Broadcaster ¶
type Broadcaster[T any] struct { // contains filtered or unexported fields }
Broadcaster is a thread-safe fan-out hub: every subscriber gets its own buffered channel, and Publish delivers a value to all of them. Slow subscribers whose buffer is full are skipped rather than blocking the publisher — they are expected to resync on their next SSE reconnect.
It replaces the per-type pub/sub that an in-memory store and a timer would otherwise each reimplement: embed or hold a Broadcaster[T] and the Subscribe/Unsubscribe/Publish plumbing comes for free.
Example ¶
Broadcaster fans one published value out to every subscriber without the publisher ever blocking on a slow consumer.
package main
import (
"fmt"
"github.com/nkhine/gohtmxelm"
)
func main() {
b := gohtmxelm.NewBroadcaster[string](16)
ch := b.Subscribe()
defer b.Unsubscribe(ch)
b.Publish("hello")
fmt.Println(<-ch)
}
Output: hello
func NewBroadcaster ¶
func NewBroadcaster[T any](buffer int) *Broadcaster[T]
NewBroadcaster returns a ready Broadcaster whose subscriber channels are buffered by buffer (use a small value like 16). A non-positive buffer is treated as 1.
func (*Broadcaster[T]) Publish ¶
func (b *Broadcaster[T]) Publish(v T)
Publish delivers v to every current subscriber, skipping any whose buffer is full. It never blocks.
func (*Broadcaster[T]) Subscribe ¶
func (b *Broadcaster[T]) Subscribe() chan T
Subscribe registers and returns a new buffered channel. Call Unsubscribe (typically via defer) when the subscriber is done.
func (*Broadcaster[T]) Unsubscribe ¶
func (b *Broadcaster[T]) Unsubscribe(ch chan T)
Unsubscribe removes ch and closes it. Safe to call once per channel.
type Envelope ¶
type Envelope struct {
Version int `json:"version"`
Type string `json:"type"`
Source string `json:"source,omitempty"`
Target string `json:"target"`
Payload any `json:"payload,omitempty"`
}
Envelope is the single message shape exchanged between islands and the broker. It exists in Go so servers can construct and validate broker messages with the same contract the browser and Elm use, rather than duplicating string literals. source is stamped by the broker, never the sender.
type InteractionEvent ¶
InteractionEvent is the DOM CustomEvent detail emitted as gohtmxelm:interaction-result when an interaction closes.
type Kit ¶
type Kit struct {
// contains filtered or unexported fields
}
Kit bundles reusable gohtmxelm runtime options.
func New ¶
New creates a configured reusable integration kit.
Example ¶
Mount the embedded broker runtime and render the boot script for pages that use Elm islands and a server-sent-events stream.
package main
import (
"fmt"
"net/http"
"github.com/nkhine/gohtmxelm"
)
func main() {
kit := gohtmxelm.New(gohtmxelm.Options{
AssetPath: "/gohtmxelm",
Sources: []gohtmxelm.Source{
{URL: "/api/events", Events: []string{"store-change"}},
},
})
mux := http.NewServeMux()
mux.Handle("/gohtmxelm/", http.StripPrefix("/gohtmxelm/", kit.Assets()))
fmt.Println(kit.BrowserScript())
}
Output: <script defer src="/gohtmxelm/gohtmxelm-broker.js" data-sources="[{"url":"/api/events","events":["store-change"]}]"></script>
func (*Kit) BrowserScript ¶
BrowserScript renders the script tag needed by pages that use Elm islands.
func (*Kit) InteractionScript ¶
InteractionScript renders the script tag needed by pages that use the server-rendered interaction overlay convention.
type LocaleProps ¶
type LocaleProps struct {
Locale string `json:"locale,omitempty"`
Timezone string `json:"timezone,omitempty"`
Currency string `json:"currency,omitempty"`
Messages map[string]string `json:"messages,omitempty"`
}
LocaleProps is the neutral localization payload convention used by Elm island flags. Locale is normally a BCP-47 tag (for example "en-GB"), Timezone an IANA identifier, Currency an ISO-4217 alpha code, and Messages a small key/value bundle scoped to the island.
func LocalePropsFrom ¶
func LocalePropsFrom(locale, timezone, currency string, catalog MessageCatalog, keys ...string) LocaleProps
LocalePropsFrom builds LocaleProps from a host application's message catalogue. It deliberately treats a nil catalogue as valid so pages can still mount islands with locale metadata while server-rendered views own all copy.
type MessageCatalog ¶
MessageCatalog is the minimal surface an application translator needs to expose when it wants to pass a scoped message bundle into an Elm island. The library does not own catalogue loading, fallback, interpolation, or pluralisation policy; host applications provide that behind this shape.
type Options ¶
type Options struct {
// AssetPath is the URL path where Assets is mounted. It is used by
// BrowserScript. Defaults to "/gohtmxelm".
AssetPath string
// Sources are the SSE endpoints the broker opens. Each forwarded event is
// broadcast to islands as a TypeSSEEvent envelope carrying {event, data}.
// Multiple sources are supported, so independent streams (for example a
// store stream and a timer stream) can coexist.
Sources []Source
// Debug enables browser-side runtime logging.
Debug bool
}
Options configures the reusable browser integration layer.
type Source ¶
Source describes one SSE endpoint the browser broker should connect to and the named events it should forward to islands as TypeSSEEvent.
type Stream ¶
type Stream struct {
// contains filtered or unexported fields
}
Stream is a server-sent-events writer bound to one request. It bundles the ResponseWriter, its Flusher, and the request context so handlers stop repeating the "type-assert the flusher, set the headers, flush after every write" ritual. Every write method flushes on success.
Typical use:
s, err := gohtmxelm.NewStream(w, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.Send("hello", payload)
for {
select {
case ev := <-ch:
s.Send("update", ev)
case <-s.Done():
return
}
}
Example ¶
Stream server-sent events to the browser broker. Send flushes after every write, and Done reports client disconnect.
package main
import (
"net/http"
"github.com/nkhine/gohtmxelm"
)
func main() {
handler := func(w http.ResponseWriter, r *http.Request) {
s, err := gohtmxelm.NewStream(w, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.Send("store-change", map[string]string{"key": "total", "value": "42"}); err != nil {
return
}
<-s.Done()
}
_ = handler
}
Output:
func NewStream ¶
NewStream sets the standard SSE headers and returns a Stream, or ErrStreamingUnsupported if the response cannot be flushed.
func (*Stream) Done ¶
func (s *Stream) Done() <-chan struct{}
Done reports client disconnect / server shutdown via the request context.
func (*Stream) PatchElements ¶
PatchElements writes a Datastar element patch, then flushes.
func (*Stream) PatchElementsMode ¶
PatchElementsMode writes a Datastar element patch with an explicit mode (e.g. "prepend", "append") and optional selector, then flushes.
func (*Stream) PatchSignals ¶
PatchSignals writes a Datastar signal patch, then flushes. Unlike the lower-level WriteDatastarPatchSignals (which takes a pre-encoded string), this marshals any value to JSON for a consistent contract with Send.
func (*Stream) Ping ¶
Ping writes an SSE comment heartbeat and flushes. Useful to keep idle connections alive through proxies that close quiet sockets.
func (*Stream) RemoveElements ¶
RemoveElements removes the elements matching selector via a Datastar patch.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
edge-datastar-apigw-lambda
command
|
|
|
edge-datastar-lambda
command
|
|
|
gohtmxelm
command
Command gohtmxelm scaffolds and inspects gohtmxelm projects.
|
Command gohtmxelm scaffolds and inspects gohtmxelm projects. |
|
internal/dynamo
Package dynamo is a pure-Go, in-process table that emulates a single-key DynamoDB table: CreateTableIfNotExists, PutItem (upsert by partition key), Count, Scan, and Recent.
|
Package dynamo is a pure-Go, in-process table that emulates a single-key DynamoDB table: CreateTableIfNotExists, PutItem (upsert by partition key), Count, Scan, and Recent. |
|
internal/simviz
Package simviz drives the simnet contract harness as a live, watchable stream.
|
Package simviz drives the simnet contract harness as a live, watchable stream. |
|
internal/statement
Package statement is the demo's bank-statement domain: a GBP account whose funds transfers live in an in-memory DynamoDB-style table, and a server-selected date range that every front-end surface observes over SSE.
|
Package statement is the demo's bank-statement domain: a GBP account whose funds transfers live in an in-memory DynamoDB-style table, and a server-selected date range that every front-end surface observes over SSE. |
|
internal/stopwatch
Package stopwatch is the demo's server-authoritative timer domain: a single Go-owned stopwatch whose state every front-end surface (HTMX controls, Datastar readout, Elm analytics) observes over SSE.
|
Package stopwatch is the demo's server-authoritative timer domain: a single Go-owned stopwatch whose state every front-end surface (HTMX controls, Datastar readout, Elm analytics) observes over SSE. |
|
internal
|
|
|
simnet
Package simnet is a self-contained, dependency-free deterministic simulation harness for the gohtmxelm pattern: Go owns authoritative state, a Broadcaster fans changes out over SSE, and the browser broker relays them to HTMX, Datastar, and Elm surfaces that must all converge to the same state.
|
Package simnet is a self-contained, dependency-free deterministic simulation harness for the gohtmxelm pattern: Go owns authoritative state, a Broadcaster fans changes out over SSE, and the browser broker relays them to HTMX, Datastar, and Elm surfaces that must all converge to the same state. |