gohtmxelm

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 16, 2026 License: MIT Imports: 11 Imported by: 0

README

gohtmxelm

gohtmxelm is a small Go-first integration library for combining HTMX, Datastar, Elm islands, and Server-Sent Events in existing Go applications.

The package does not impose an application framework. It provides the reusable bridge pieces: SSE response helpers, Datastar patch helpers, HTMX response helpers, Elm island HTML conventions, and an embedded browser broker runtime.

Install

go get github.com/nkhine/gohtmxelm
go install github.com/nkhine/gohtmxelm/cmd/gohtmxelm@latest

Check local tooling:

gohtmxelm doctor

Scaffold a project

gohtmxelm init generates a complete, runnable example — a server-owned counter pushed to an Elm island over SSE — and wires up the toolchain for you.

# New project: scaffold + build into ./myapp, then run
gohtmxelm init myapp
cd myapp && make dev            # http://localhost:8080

# SSE-only, no Elm and no build step (runs with `go run .`)
gohtmxelm init myapp --minimal

Run it inside a directory that already has a go.mod and init instead drops a self-contained, mountable gohtmxelmkit/ package and prints the exact snippet to wire it into your existing chi router — it never touches your main.go:

cd my-existing-app
gohtmxelm init
# then: kit := gohtmxelmkit.New("/counter"); kit.Mount(r)

Flags: --module <path> (module path for a new project), --minimal, --no-build (write files only), --force (overwrite). After upgrading the library, re-sync the Elm contract with gohtmxelm vendor-elm.

Optional: deploy scaffolding

Add a streaming-correct container setup and CI with --deploy at init time, or gohtmxelm deploy on an existing scaffold:

gohtmxelm init myapp --deploy     # scaffold + Dockerfile + CI
gohtmxelm deploy                  # add it to an app you already scaffolded

This writes a multi-stage Dockerfile (distroless, non-root), .dockerignore, docker-compose.yml, a GitHub Actions workflow that builds, tests, and pushes the image to GitHub Container Registry (ghcr.io/<owner>/<repo>) using the built-in GITHUB_TOKEN, and a DEPLOY.md covering the SSE-specific gotchas (disable proxy response buffering, long idle timeouts, prefer HTTP/2). It is template-only — it never runs a deploy or handles credentials.

Use In A Go App

Mount the embedded browser runtime:

import "github.com/nkhine/gohtmxelm"

kit := gohtmxelm.New(gohtmxelm.Options{
	AssetPath: "/gohtmxelm",
	Sources: []gohtmxelm.Source{
		{URL: "/api/events", Events: []string{"store-hydrate", "store-change"}},
	},
})

mux.Handle("/gohtmxelm/", http.StripPrefix("/gohtmxelm/", kit.Assets()))

Render the broker script on pages that mount Elm islands:

template.HTML(kit.BrowserScript())

Render the interaction runtime on pages that open server-rendered dialogs, pickers, menus, drawers, or transient toasts:

template.HTML(kit.InteractionScript())
template.HTML(gohtmxelm.InteractionRoot(""))

Open a server-rendered interaction fragment with normal HTML attributes:

<button
  data-gohtmxelm-open="/api/interactions/confirm"
  data-gohtmxelm-status="#delete-result">
  Delete item
</button>
<span id="delete-result">awaiting click</span>

Render an Elm island mount point:

html, err := gohtmxelm.ElmIsland("counter", "Counter", map[string]any{
	"initial": 0,
})

The browser broker looks Counter up on window.Elm by name and hydrates it with the JSON-encoded props. To build the island, vendor the canonical BrokerPort contract into your Elm source directory and import BrokerPort:

// e.g. a go:generate step or small build helper
os.WriteFile("elm/BrokerPort.elm", []byte(gohtmxelm.ElmBrokerPort()), 0o644)

BrokerPort.elm is the Elm peer of the wire contract — it stamps and validates the same ProtocolVersion as Envelope (Go) and the broker runtime (JavaScript), and a test in the package fails if the three drift. Re-vendoring after a library upgrade is how you stay in sync. Each island is a normal Browser.element that subscribes to brokerIn, classifies envelopes with decode, and sends with ready / sendStateSet / sendMessage / sendHtmxSwap.

Pass locale metadata and a scoped message bundle into an island without making the library own your translation system:

locale := gohtmxelm.LocalePropsFrom("en-GB", "Europe/London", "GBP", translator,
	"common.save",
	"counter.title",
)
props, err := gohtmxelm.LocalizedProps(map[string]any{"initial": 0}, locale)
if err != nil {
	// base props must encode as a JSON object
}
html, err := gohtmxelm.ElmIsland("counter", "Counter", props)

gohtmxelm only defines the transport convention:

{
  "locale": "en-GB",
  "timezone": "Europe/London",
  "currency": "GBP",
  "messages": {
    "common.save": "Save"
  }
}

The host application still owns locale resolution, supported locales, catalogue loading, fallback rules, interpolation, pluralisation, persistence, and date/money formatting.

Stream Server-Sent Events from a handler. A Stream bundles the ResponseWriter, its flusher, and the request context, and flushes on every write. Pair it with a Broadcaster[T] and Serve runs the whole subscribe → hydrate → fan-out loop:

func handler(w http.ResponseWriter, r *http.Request) {
	stream, err := gohtmxelm.NewStream(w, r)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	gohtmxelm.Serve(stream, store.Events(),
		func(s *gohtmxelm.Stream) error { return s.Send("store-hydrate", store.Snapshot()) },
		func(s *gohtmxelm.Stream, e store.Event) error { return s.Send("store-change", e) },
	)
}

Each write method has a direct form too — s.Send(event, data), s.PatchElements(html), s.PatchSignals(data), s.Ping() — and the lower-level PrepareSSE / WriteSSE / WriteDatastarPatch* functions remain available.

Architecture

The intended ownership model is:

Layer Owns
Go durable state, validation, commands, SSE fan-out
HTMX server-rendered request/response fragments
Datastar declarative local signals and server-pushed DOM/signal patches
Elm typed client-side islands and local state machines

Keep the DOM ownership boundaries physical:

  • HTMX should not swap inside an Elm root.
  • Datastar should not patch inside an Elm root.
  • Elm should not render inside a Datastar-owned region.
  • Go is the convergence point for shared state.

Repository Layout

.                       public importable Go package (module root)
elm/BrokerPort.elm      canonical Elm-side wire contract (embedded; vendor via ElmBrokerPort)
runtime/                embedded browser broker runtime
internal/simnet/        deterministic simulation harness (internal testing aid)
cmd/gohtmxelm/          CLI for init and doctor workflows
demo/internal/dynamo/   in-memory DynamoDB-style table (no external service)

demo/                   reference app users can copy from
demo/main.go            demo server and routes
demo/elm/               demo Elm islands and elm.json (also sources ../elm/BrokerPort.elm)
demo/static/            demo browser assets
demo/internal/store/    demo state store
demo/internal/simviz/   drives simnet live for the contract simulator card
demo/internal/ui/       demo templ shell/page composition
demo/internal/ui/components/
                        demo templ components

docs/                   architecture notes and deeper rationale

Reference Demo

The demo/ directory is a complete reference implementation that shows the library pattern in a real Go app. It includes:

  • a shared message workbench using HTMX, Datastar, Elm, Go, and SSE
  • a server-owned stopwatch using HTMX controls, Datastar live patches, and Elm lap analytics
  • a bank-statement view: an Elm range picker filters Go-owned transfers while HTMX renders the table and Datastar pushes the summary
  • a Seed card that fakes account transfers (the heritage treasury payment-row model, with gofakeit counterparty names) into the statement's in-memory DynamoDB-style table (demo/internal/dynamo, no Docker/AWS). Submitting the form writes the rows and broadcasts the change, so the statement's HTMX table, Datastar summary, and Elm picker all update — the statement data is generated at runtime, not hard-coded.
  • a Localization boundary card with a demo-owned TOML-style catalogue and locale registry. Changing the language uses HTMX to re-render server copy, Datastar to bind localized date/money signals, and Elm island flags built via gohtmxelm.LocalizedProps. The example intentionally keeps catalogue and formatting policy in demo/internal/localize, not in the reusable package.
  • an Edge Datastar SSE card that opens same-origin /api/edge-datastar/stream, loops a visual browser-to-edge-to-Lambda trace, and proves Datastar applies datastar-patch-signals and datastar-patch-elements frames and re-binds signal/click handlers inside a morphed element. The same handler is wrapped by a Go Lambda response-streaming entrypoint and a floci/Pulumi local API Gateway stack under infra/.
  • a Local SSO login card that runs a complete local browser redirect flow: /api/sso/start sets state, a fixture identity-provider form issues a one-use code after approval, /api/sso/callback validates state and writes an HttpOnly session cookie, and HTMX rehydrates the signed-in claims panel. Successful login/logout also drives a demo-wide auth presence signal: every card header shows red logged-out, green online, or orange idle, while the SSO card renders the same state through HTMX, Datastar signal patches, and an Elm island.
  • a Contract simulator that runs the simnet harness live: it replays a deterministic run over the library's own Broadcaster and visualises the full request path (Go → Broadcaster → SSE → bridge.js → Elm/HTMX/Datastar) under an adversarial network — dropping, delaying, and partitioning SSE while the convergence invariant holds (or, with recovery off, fails reproducibly). See Testing the pattern.
  • local Elm source under demo/elm
  • demo-only browser assets under demo/static

Run it:

make dev

make dev and make watch only build local browser/server assets and run the Go demo app. They do not start floci, Pulumi, API Gateway, or Lambda resources. Use make edge-infra-up only when you want the local AWS edge stack.

By default the dev server runs over HTTP/2 with a self-signed localhost certificate (https://localhost:8091). HTTP/2 multiplexes every SSE stream and request over a single connection, which matters on the / gallery page: it opens one SSE stream per card plus the broker stream, and over plain HTTP/1.1 that hits the browser's ~6-connections-per-host limit and starves htmx requests (e.g. the SSO logout POST hangs). Your browser will warn once about the self-signed cert — accept it for the session. To fall back to plain HTTP, pass TLS=0 (e.g. make watch TLS=0).

Run with rebuild/restart while editing Go, templ, Elm, or browser assets:

make watch

make watch uses Air:

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

Routes:

/                   all demo examples
/examples/message   message workbench only
/examples/stopwatch stopwatch only
/examples/statement bank-statement view only
/examples/seed      seed transfers only
/examples/localization i18n/l10n boundary only
/examples/edge-datastar Datastar SSE through the edge only
/examples/sso-local local SSO redirect/session demo only
/examples/simulator contract simulator only
/examples/confirm-dialog and other interaction examples

Run the local floci/API Gateway Lambda streaming stack:

make edge-infra-up
make edge-infra-output

See Datastar over SSE through the edge for the Starbase /api/* origin wiring, SigV4 direct-call requirement, and Lambda response streaming notes.

The local SSO simulator is built into make dev; it does not require floci or Alcove. See Local SSO login simulator.

The Makefile copies Datastar from:

/Users/nkhine/go/src/github.com/starfederation/datastar/bundles/datastar.js

Override that path if needed:

make dev DATASTAR_SRC=/path/to/datastar/bundles/datastar.js

Testing the pattern

The contract this library makes is convergence: after writes stop and the network settles, every surface presents the same state Go owns. That guarantee is non-trivial because Broadcaster is intentionally lossyPublish skips any subscriber whose buffer is full and never blocks, so a delivered stream alone cannot keep surfaces in sync. Convergence depends on reconnect-and-rehydrate (and/or idempotent snapshot events): a surface that misses an event reconnects and Serve re-sends the current snapshot.

Two layers verify this, holding the model and the implementation to the same invariants (simnet.CheckConvergence / simnet.CheckMonotonic):

  • simnet — a self-contained, dependency-free deterministic simulation kernel (PADST in spirit: single-threaded, seed-reproducible, invariant-checked after every step). It models the broadcast→SSE→converge contract under a fault profile (drop / delay / duplicate / reorder / partition) for both snapshot and delta event semantics, and proves convergence holds with resync and diverges without it. A failing run is reproducible from its Seed.
  • Real-code testsbroadcaster_synctest_test.go and serve_contract_test.go drive the shipped Broadcaster / Stream / Serve with Go's testing/synctest and httptest, asserting the same invariants against the actual goroutines and SSE wire.
go test ./... -race          # both layers
go test ./internal/simnet/   # the contract model only
PLAYWRIGHT_BASE_URL=https://localhost:8091 make test-browser

The Contract simulator card (/examples/simulator) runs the simnet harness live and visualises it. It auto-loops new seeds, but you can drive it: play/pause/step, change fault intensity, flip snapshot⇄delta, and break it (turn resync off) to watch divergence. Each completed run is recorded in a ledger with deterministic metrics (faults weathered, max lag); violations are persisted to sim-violations.jsonl (override with SIM_LOG=...), reloaded on startup, and replayable by seed straight from the ledger. See docs/09 — Testing the contract.

CSP Note

Datastar v1.0.2 compiles declarative expressions such as data-signals, data-text, and @post(...) in the browser. If you use those expressions, your CSP needs to allow that evaluation path. The reference demo currently uses script-src 'self' 'unsafe-eval' for this reason.

Stability

The exported Go API of github.com/nkhine/gohtmxelm follows semantic versioning — additive within a major version, breaking changes only on a major bump. The on-the-wire broker format is versioned separately by ProtocolVersion. The gohtmxelm CLI and everything under internal/ (including the simnet harness) carry no compatibility promise. Full policy: STABILITY.md.

License

MIT © Norman Khine.

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

Examples

Constants

View Source
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.

View Source
const (
	TargetBroker    = "broker"    // handled internally
	TargetBroadcast = "broadcast" // every island
	TargetOthers    = "others"    // every island except the sender
)

Routing targets understood by the broker's router.

View Source
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

View Source
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

func Assets() http.Handler

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

func BrowserScript(opts Options) string

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

func ElmContractHandler() http.Handler

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

func ElmIsland(id string, module string, props any) (string, error)

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="{&#34;count&#34;:3}"></div>

func Flush

func Flush(w http.ResponseWriter) bool

Flush flushes a streaming response when the server supports it.

func InteractionOpenAttrs

func InteractionOpenAttrs(url string, statusTarget string) string

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

func InteractionResultAttrs(result string) string

InteractionResultAttrs serialises the attributes for an element that closes the nearest interaction fragment with a result value.

func InteractionRoot

func InteractionRoot(id string) string

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

func InteractionScript(opts Options) string

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

func MarshalInteractionEvent(target, result string) (string, error)

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

func WriteDatastarPatchElements(w io.Writer, elements string) error

WriteDatastarPatchElements writes a Datastar element patch event to an SSE response. The supplied HTML should include stable ids for Datastar targets.

func WriteDatastarPatchElementsMode

func WriteDatastarPatchElementsMode(w io.Writer, mode, selector, elements string) error

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

func WriteDatastarPatchSignals(w io.Writer, signals string) error

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}

func WriteSSE

func WriteSSE(w http.ResponseWriter, event string, data any) error

WriteSSE writes a named Server-Sent Event with JSON-encoded data.

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

type InteractionEvent struct {
	Target string `json:"target"`
	Result string `json:"result"`
}

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

func New(opts Options) *Kit

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="[{&#34;url&#34;:&#34;/api/events&#34;,&#34;events&#34;:[&#34;store-change&#34;]}]"></script>

func (*Kit) Assets

func (k *Kit) Assets() http.Handler

Assets returns the embedded runtime assets for this kit.

func (*Kit) BrowserScript

func (k *Kit) BrowserScript() string

BrowserScript renders the script tag needed by pages that use Elm islands.

func (*Kit) InteractionScript

func (k *Kit) InteractionScript() string

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

type MessageCatalog interface {
	Messages(keys ...string) map[string]string
}

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

type Source struct {
	URL    string   `json:"url"`
	Events []string `json:"events"`
}

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
}

func NewStream

func NewStream(w http.ResponseWriter, r *http.Request) (*Stream, error)

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

func (s *Stream) PatchElements(elements string) error

PatchElements writes a Datastar element patch, then flushes.

func (*Stream) PatchElementsMode

func (s *Stream) PatchElementsMode(mode, selector, elements string) error

PatchElementsMode writes a Datastar element patch with an explicit mode (e.g. "prepend", "append") and optional selector, then flushes.

func (*Stream) PatchSignals

func (s *Stream) PatchSignals(signals any) error

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

func (s *Stream) Ping() error

Ping writes an SSE comment heartbeat and flushes. Useful to keep idle connections alive through proxies that close quiet sockets.

func (*Stream) RemoveElements

func (s *Stream) RemoveElements(selector string) error

RemoveElements removes the elements matching selector via a Datastar patch.

func (*Stream) Send

func (s *Stream) Send(event string, data any) error

Send writes a named event with JSON-encoded data, then flushes.

Directories

Path Synopsis
cmd
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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL