Go server-driven and islands
fuaran-go emits complete static output and never owns a frontend codebase. That
leaves a question this page answers: where does interactivity come from?
Two delivery modes, and they compose with the same tree you authored in Authoring Fuaran trees in Go:
- Static + islands — emit a mostly-static page whose values are already resolved, and hydrate only the regions that need to be live. One render call.
- Server-driven — hold the tree and its state in Go, stream canonical
TreeOpframes to a thin generic client, and round-trip interactions back.
In both, Go emits and drives; the browser renders. The Go program authors no client code in either.
The static baseline
Both modes build on the plain render, so it is worth being precise about what it gives you:
html, err := renderer.RenderHTML(tree, renderer.BindingSources{})
html, err = renderer.RenderHTML(tree, renderer.BindingSources{
Values: map[string]wire.Value{"users": wire.Int(42)}, // the identity-keyed map
Now: "2026-08-02T06:59:24Z", // the host instant, ISO-8601 UTC
Locale: "en-GB", // the ambient BCP-47 tag
})
RenderHTML returns a body fragment, not a document — you own the shell.
renderer.ReferenceCSS() hands back the reference stylesheet to embed or serve.
The second argument is what the host furnishes this render pass. Its zero value
is the headless baseline: Static bindings resolve, the rest placeholder to an
em-dash, and a declared State default resolves ahead of the placeholder while a
host-supplied source wins over both. renderer.Sources(map[string]wire.Value{…})
lifts a bare map when values are all you have.
Now is what Binding.Now and Format.Since resolve against. The clock lives
here, never on the wire and never read during resolution — resolve it once per
render pass and hold it, or two Now slots in one tree can disagree, and a
replayed op-stream re-supplies the instant it recorded so a replay reproduces the
original render. A declared grain truncates it before anything projects it. The
"" default means this host furnishes no clock, and the slot then resolves to
absence rather than to a plausible wrong date.
Locale is the tag a LocaleSource.Ambient reads ("" = the runtime default).
The Format cases this host renders — Since, RelativeTime, Duration — are
locale-independent by declaration and consult no tag; Number / Currency /
Percent / Date take their text from a locale database and resolve to absence
here. renderer.ResolveLocaleTag hands you the tag a document asked for if you
want to render those four yourself.
The destination policy is ambient: RenderHTML runs DenyNonLocalEgress()
with no caller opt-in, so a decoded tree may point at its own origin and nowhere
else. Widening is a named call:
policy := renderer.DenyNonLocalEgress().
AllowOrigin(renderer.HostSuffix("cdn.example"), renderer.EgressMedia).
AllowOrigin(renderer.ExactHost("docs.example"), renderer.EgressHyperlink)
html := renderer.RenderHTMLWithEgress(tree, sources, policy)
The classes are EgressHyperlink, EgressMedia, EgressRoute,
EgressDownload, EgressFileRead and EgressEmbed — a rule declares what may
be reached and for what, and everything unlisted is denied.
Islands: static by default, live where you say
A host marks individual subtrees as islands per render. The marker never rides the wire — which subtree is live is a rendering decision, not a property of the tree — so it is a parameter:
import "github.com/fuaran-ui/fuaran-go/renderer"
html, err := renderer.RenderWithIslands(tree, nil, map[string]string{
"chart-panel": "chart", // node id -> island id
})
The map takes a node id in the tree to the island id the boundary and
payload carry. What comes out is the ordinary static render, with each island's
element wrapped in a <div class="fuaran-island" data-fuaran-island="…">
boundary, followed by one scoped <script type="application/json"> per island
carrying only that subtree's canonical wire tree.
Three properties are worth knowing because they are what make islands safe to reach for:
- Zero islands is byte-identical to a plain render. A nil or empty map
returns exactly
RenderHTML(tree, sources)— no wrappers, no scripts. Adding the call to a page that declares no islands changes nothing. - No hydration mismatch, by construction. The boundary's children are exactly the island node's normal static render, which is what the client re-renders into the wrapper. There is nothing for a reconciler to disagree about.
- The static and island surfaces resolve values identically. Both run the same state-seeding pass. If they differed, one document would render two values depending only on whether a region happened to be marked — so they do not.
The embedded payload is the island subtree's canonical wire JSON, deliberately unfiltered. It is the tree, not a rendering of it, and the client consuming it is a conformant host applying its own policy at its own emission sites. Rewriting a destination inside the payload would hand the client a tree that no longer round trips.
The destination policy does not widen when you mark an island
RenderWithIslands runs the ambient DenyNonLocalEgress policy, exactly as the
static path does. That is the point: a host must not widen its egress merely by
marking a region live. A wider posture is reached by name, through
renderer.RenderWithIslandsAndEgress(tree, sources, islands, policy) — an
explicit call, so it shows up in review.
Server-driven: the tree lives in Go
The serverdriven package holds the tree and streams diffs. Its whole state is
one sentence long: the tree, plus your handler's closure.
import "github.com/fuaran-ui/fuaran-go/serverdriven"
session := serverdriven.NewSession(tree, handler)
conn := serverdriven.NewConnection("c1", session, channel)
The handler is your decision function
type Handler func(tree wire.Node, ev Event) ([]wire.Obj, error)
Given the current tree and a structurally-validated event, it returns the
TreeOps to apply — or an error to refuse the event. The host's model lives in
the closure, which is the natural Go shape:
func counterHandler() serverdriven.Handler {
count := 0
return func(tree wire.Node, ev serverdriven.Event) ([]wire.Obj, error) {
if ev.NodeID != "inc" || ev.Event != "click" {
return nil, errors.New("only the inc button click is handled")
}
count++
return []wire.Obj{{Tag: "UpdateProp", Fields: map[string]wire.Value{
"target": wire.Str("count"),
"path": wire.Str("Value"),
"value": wire.Int(int64(count)),
}}}, nil
}
}
What happens on each event
Session.Step runs five checks in order, and stops at the first that refuses.
Four are always on; the fifth is the capability gate, which you turn on:
- Does the node exist in the current server tree? No ⇒
UnknownNode— a stale or forged id. - Does that node's kind accept this event? No ⇒
IllegitimateEvent. The table is default-deny: a kind not listed accepts nothing. Today's listed kinds areButton(click),Select(change),Form(submit/change/input),Filters(change/input/click),FileUpload(change/file-read),TabsandStepper(click/change), andDisclosure(click/change/toggle). - Does your handler accept it? An error ⇒
DispatchDenied. - Does every op it returned introduce only mounts you granted? No ⇒
CapabilityDenied, naming the ungranted capability ids. This one is opt-in: a session withoutWithCapabilityGateskips it entirely, and a session with one denies by default. It is the authority boundary on what your HANDLER returns, which is a different question from the trust boundary on what the client sends — a handler is your code, but it routinely computes ops from client-supplied values, so "I wrote this function" is not "I intended this particular mount". Checks 1–4 are recursive throughBatch; so is this. - Does every op it returned actually apply? No ⇒
DispatchDenied, and the tree does not move at all — a partially-applying set advances nothing.
And a panic from your handler is recovered at this boundary and reported as
HandlerPanicked — the connection survives, the tree is untouched. Step's
"never panics" is now true rather than aspirational: on the WebSocket transport
a handler panic used to leave a goroutine net/http does not recover, taking
the process with it. Session.WithOnHandlerPanic(sink) hands you the recovered
value; the reject deliberately does not carry it, because a panic message can
hold whatever the handler was holding and a reject travels to the client.
A refused step mutates nothing and pushes no frame:
type Reject struct {
Reason RejectReason // UnknownNode | IllegitimateEvent | DispatchDenied
// // | CapabilityDenied | HandlerPanicked
NodeID string
Message string
MissingCapabilities []string // CapabilityDenied only
}
session := serverdriven.NewSession(tree, handler).
WithCapabilityGate(serverdriven.NewCapabilityGate("storage.read"))
CapabilityGate.AuditMounts(tree) gives the whole-tree view — which mounts are
live and which are blocked, in document order.
Wire serverdriven.WithOnReject(sink) at construction to audit refusals — it is
the always-on hook, and a refusal that nobody logs is a refusal nobody can
diagnose.
WithRejectProjection(project) is the other half: it lets a refusal reach the
client, as an ordinary frame of TreeOps you project from the reject. Until
you wire it, a rejected step pushes nothing, so from the browser a refused click
and a click that never arrived are the same event — the operator is told, the
person who clicked is not.
conn := serverdriven.NewConnection("c1", session, ch,
serverdriven.WithOnReject(func(r serverdriven.Reject) { log.Printf("%+v", r) }),
serverdriven.WithRejectProjection(func(r serverdriven.Reject) []wire.Obj {
return []wire.Obj{ /* set a banner's text, disable a control, ... */ }
}))
It is a projection you supply rather than a reject envelope this package
invents, because showing a refusal is a UI decision — a banner, a toast, a
field-level message — and a new client-facing wire shape would have to be
specified, versioned and adopted by every client before it could carry any of
them. Ops are the vocabulary both ends already speak. Return nil for a reject
the user should not see; the frame is sequenced and replays like any other.
What a frame is
type Frame struct {
Seq int
Ops []wire.Obj
}
Canonical TreeOps, tagged with a per-connection sequence number. Not DOM
patches — that difference is what keeps this host render-runtime-free. A
conformant client re-renders by applying those ops with the same apply engine
every host ships, which is why the loop can be proved end-to-end in Go with no
browser in sight: a client holding its own copy of the tree and applying each
frame converges to the byte-identical server tree.
EncodeFrameJSON renders a frame as {"ops":[…],"seq":N} — itself canonical,
since "ops" sorts before "seq" under the Ordinal key order:
{"ops":[{"$type":"UpdateProp","path":"Source","target":"count","value":1},{"$type":"RemoveNode","target":"x"}],"seq":7}
EncodeSSE wraps that body in one SSE event — id: 7, event: patch, the
single-line data: (canonical bytes carry no embedded newlines), and the
terminating blank line.
The client→server message is deliberately not canonical wire — it is a small control message, so ordinary JSON is apt:
{"connId":"c1","nodeId":"inc","event":"click","payload":"","lastSeq":4}
DecodeEvent parses it. The driver does not trust any of it: nodeId and
event go straight into the checks above.
Reconnects
Every frame carries Seq, and every inbound event carries the client's
LastSeq. A bounded per-connection buffer retains recent frames, so a transport
drop is recoverable:
replayed, err := conn.Resync(lastSeq) // re-pushes every retained frame newer than lastSeq
The buffer is bounded at DefaultReplayBufferCapacity (512 frames, overridable
with WithReplayBufferCapacity) and evicts oldest-first, so a client that never
reconnects cannot grow server memory without limit. A client returning from
behind the retained window gets the retained tail — state it did not see is gone,
which is a real limit rather than a rounding error, and the remedy is to re-seed
that client with a fresh tree.
Applying frames must be idempotent on the client: skip any frame whose Seq is
at or below the last one applied.
Transports
The library defines no HTTP routes. There is no /events, no /dispatch —
the endpoints are yours to name and mount. What the package supplies is the
framing and the upgrade, and the driver never sees a transport type at all. It
sees only:
type Channel interface {
Push(frame Frame) error
Receive(handler func(Event))
Close() error
}
One channel is one live connection. A multiplexing backend owns the
connId → Channel registry above this seam. Three implementations ship:
InMemoryChannel — the reference channel. Pushed() returns every frame in
order and Send(ev) simulates an inbound event, so the whole loop is testable
headlessly.
SSE (sse.go) — ServeSSE(w, r) writes the streaming headers and returns
the channel plus the client's reconnect cursor read from Last-Event-ID, which
you hand straight to Resync. Keep the request goroutine alive for as long as the
connection should stream. SSE is one-directional, so the client→server path is a
companion POST endpoint you wire to SSEChannel.Inbound.
WebSocket (ws.go) — a hand-written RFC 6455 handshake and frame codec, no
third-party module. ServeWebSocket(w, r) upgrades and returns the channel; run
ListenAndRecover() in its own goroutine for the inbound read loop (it answers
pings with pongs itself).
The read loop must run under a recover, and does not by itself
net/http recovers a panic in the goroutine it started for a request. The read
loop runs in one you started, so a panic there — in the framing, or in your
own inbound handler, which this package cannot vouch for — takes the process
down rather than the connection, ending every other connection the server holds.
ListenAndRecover() is Listen() under that boundary and returns the recovered
value as an ordinary error; use it unless you have a boundary of your own.
Inbound frames are capped before they are allocated
A frame header declares its own payload length, so a peer can claim a size
before sending a byte of it. MaxFrameBytes (1 MiB) bounds the declared length
before it is narrowed to int and before anything is allocated, which is the
only ordering that helps: the declared value is an unauthenticated uint64, and
0xFFFFFFFFFFFFFFFF narrows to -1. A breach is answered with a close frame
carrying RFC 6455 status 1009 and returned as a *ProtocolError; unmasked
client frames and control frames over 125 bytes are refused the same way with
1002. NewWSChannelWithLimit raises the ceiling for a host whose client
genuinely sends larger messages — a non-positive value falls back to the default
rather than meaning "unbounded".
This is a transport cap and is not the §21 wire limits, which bound the structure of a document already read. Both apply.
Deadlines, cancellation, and closing
Close() closes the underlying socket, not just a flag. ListenContext(ctx)
ends the loop when ctx is cancelled — by closing the socket, because a
blocking Read on a net.Conn cannot be interrupted any other way.
The loop refreshes a read deadline (DefaultReadTimeout, 60s) before every
frame and sends a server ping every DefaultPingInterval (25s), so a healthy
idle connection stays warm while a half-open one — lid closed, NAT entry
expired, cable pulled — is reclaimed instead of parking a goroutine and a
descriptor forever. SetReadTimeout(readTimeout, pingEvery) overrides both
before Listen; a non-positive readTimeout disables the deadline, which is a
real choice for a host keeping liveness elsewhere and deliberately not the
default.
SetOnDrop reports what the loop discards — a client frame that will not
decode, a pong reply that could not be written. Both were silent, so a client
sending malformed events looked exactly like a client sending nothing.
What the host still owns
Two obligations this package cannot discharge for you, because they belong to
your http.Server and your routes:
srv := &http.Server{
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second, // not applied to a hijacked WS conn
WriteTimeout: 0, // 0 for the SSE stream — it is long-lived by design
IdleTimeout: 120 * time.Second,
}
ReadTimeout and ReadHeaderTimeout bound a slow-loris client on every
ordinary route. WriteTimeout must be 0 on a mux serving the SSE stream, or
the stream is cut mid-connection; put the SSE endpoint on its own server, or
rely on the connection's own read deadline, if you need a write timeout
elsewhere.
And bound the companion POST body — use DecodeEventRequest(w, r), which reads
through an http.MaxBytesReader at MaxEventBytes, rather than reading
r.Body yourself. An event is a small control message; io.ReadAll on an
unauthenticated body is a memory-exhaustion primitive reachable with nothing
but the URL. An oversized body comes back as a *http.MaxBytesError — answer
413.
Inbound events are serialised for you
Connection holds a mutex over Handle and Resync, so the session tree, the
sequence counter, the replay buffer and the response writer are never touched
concurrently. This is not merely documented discipline: the SSE companion POST
is an ordinary HTTP handler, so net/http hands you inbound events on parallel
goroutines whether or not you want it to, and no amount of host care can
serialise what it did not schedule. Two rapid clicks used to interleave SSE
bytes and lose or duplicate a seq. SSEChannel.Push is guarded too, which
covers a host pushing out-of-band (a heartbeat) alongside the driven frames.
The WebSocket origin policy is the one thing to read before shipping
The same-origin policy does not cover WebSockets. A browser will let a page on
any origin open a socket to your server, with the victim's cookies attached. So
ServeWebSocket's zero-value policy is same-origin only, and widening it is a
deliberate act through ServeWebSocketWithOptions:
ch, err := serverdriven.ServeWebSocketWithOptions(w, r, serverdriven.UpgradeOptions{
AllowedOrigins: []string{"https://app.example.com"},
})
if errors.Is(err, serverdriven.ErrOriginNotAllowed) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
Same-origin is always allowed and needs no entry — the list is additive, so
naming a partner origin can never lock out the page you serve. Two values carry
teeth: "*" disables the check entirely, and "null" matches the origin any
sandboxed iframe can mint at will, so it is nearly as broad. Before adding
either, be sure the socket carries no ambient authority, or authenticates every
peer independently of the browser's credentials.
The origin check runs before the hijack, so a refused upgrade leaves the
ResponseWriter intact and you can still write a 403.
A missing Origin header is allowed by default, and that is deliberate rather
than an oversight: RFC 6455 requires browsers to send it, so its absence means
the peer is not a browser — a CLI, a service, a test, which is exactly what a
headless host exists to serve. Refusing them would buy nothing, since a
non-browser peer can send any origin it likes. Set DenyMissingOrigin when the
socket is only ever opened by page JavaScript.
What the driver never holds
Worth stating explicitly, because a reader could reasonably assume otherwise.
There is no page cursor, no sort cursor, no per-field validity or dirty set, no
focus, no scroll position, and no per-user model. Connection adds a sequence
number and a replay buffer, which are transport state and carry no UI meaning.
Grid interaction does not reach this driver at all. DataGrid has no entry in
the legitimacy table, so a sort click, a page change or an edit commit addressed
to a grid is refused as IllegitimateEvent before any handler runs. That is
default-deny working, not a gap: what the client is handed instead is the
declaration — sortable per column, the sort and page state keys, the declared
read-only columns and the edit destination all ride the wire as data, with the
resolved initial state already in the emitted bytes. A host that wants a server
round trip for a sort drives the corresponding State key from a node that does
take an event.
Form events reach the driver, and it decides nothing about them. A rule
declared on a form field — format, pattern, length bounds, a cross-field
compare — is decoded, re-encoded, and validated pre-emit for the slots a control
can honour, but this driver does not enforce it. Enforcement is your handler's
decision. This is a real divergence from the reference driver, which re-checks
declared rules server-side unconditionally, and it is recorded rather than
closed. Two related gaps sit beside it: the form projection emits a generic
labelled <input> without the control's type, its required state, or its
declared rule as HTML constraint attributes. Both are form-fidelity work, older
and wider than the rule vocabulary that made them visible.
If your form needs enforcement, put it in the handler. Nothing below you will do it for you.
Verifying
.\run.ps1 # gofmt check -> go vet -> go build -> go test
The serverdriven tests cover the loop end to end without a browser: dispatched
event → server apply → frame → client apply → byte-identical tree, including the
reconnect path with no double application of a frame already seen.