Contents
This page documents the ws package (github.com/zatrano/goui/ws): the
Session/Hub lifecycle, the wire frame format, and how to wire it into any
HTTP stack through ws.Server and a framework adapter.
import "github.com/zatrano/goui/ws"
1. Architecture at a glance
Browser (goui.js)
│ event / prefetch / activate (JSON frames over WebSocket)
▼
Session ──► Component.HandleEvent / Mount
│
▼
Render HTML ──► Diff (old tree → patches) ──► Frame(render)
│
▼
Hub (sessions map, grace-period reconnect, Broadcast)
- The browser connects to
/goui/ws?component=<name>&locale=<locale>. - The server looks up
<name>in thecore.Registry, creates aSession, mounts the component, and sends asessionframe followed by the firstrenderframe (a fullOpReplacepatch). - User interaction sends
eventframes; the server callsHandleEvent, re-renders, diffs old vs. new HTML, and streams back the minimal set of patches as arenderframe. - Optionally,
prefetchsilently mounts a component ahead of navigation;activatepromotes it into the visible set and sends its first render. - On disconnect, the session is not torn down immediately — it is kept alive for a grace period so a reconnect with the same session ID resumes exactly where it left off.
2. Session lifecycle
func NewSession(conn *websocket.Conn, translator *i18n.Translator, locale string) *Session
A Session owns every core.Component instance created for one logical
browser tab, for its entire lifetime — including across reconnects. Key
fields (all otherwise private) exposed via methods:
| Method | Purpose |
|---|---|
SetRegistry(registry *core.Registry) |
Attaches the registry used to resolve names for Prefetch/Activate and (indirectly) the initial ?component= connect. Called by ws.Server.ServeConn; you rarely call this yourself. |
MountComponent(id string, c core.Component) error |
Injects the translator/pusher/ID/Locale into c (via SetTranslator, SetPusher, and reflection into an embedded BaseComponent), then calls c.Mount(ctx) and stores it under id. Used for the component created from the initial ?component= query parameter. |
Prefetch(name string) error |
Looks name up in the registry, mounts a fresh instance without rendering it, and stores it in a side map keyed by registry name (not yet a session-visible component ID). Duplicate prefetches of the same name are a no-op. When more than ws.MaxPrefetch (5) names are prefetched, the oldest is evicted and Unmounted (simple LRU by insertion order). |
Activate(name string) (string, error) |
Promotes a prefetched component into the active set (or, if it was never prefetched, creates+mounts a fresh one), assigns it a new component ID, sends its first full render, and returns the new ID. |
Run(ctx context.Context) |
Starts the read loop and write loop as two goroutines and blocks until either the context is cancelled, the read loop exits (connection closed/error), or the write loop exits. On return, marks the session disconnected and closes the underlying connection — but does not unmount components. Called once per WebSocket upgrade, by ws.Server.ServeConn after your adapter hands it an upgraded connection. |
Reattach(conn *websocket.Conn) error / ReattachConn(conn wsConn) error |
Binds a new live connection to an existing (disconnected) session, clearing disconnectedAt. Returns ErrSessionAlreadyActive if the session already has a live connection (e.g. a stale duplicate reconnect attempt). |
SendSessionFrame() |
Enqueues a session frame carrying the session ID, so the browser can persist it (sessionStorage) for future reconnects. Sent once, right after a fresh connect (not on reconnect — the client already knows its ID then). |
SendInitialRenders() |
Enqueues a fresh full render frame for every currently-mounted active component. Called on every connect/reconnect so the browser's DOM is (re)synchronized with server state — this is what makes reconnect "just work" without any client-side state. |
Close() error |
Unmounts every active and prefetched component (Unmount(ctx) on each), clears all internal maps, closes the connection, and closes the outbound channel. Called by the Hub's cleanup loop once a disconnected session's grace period has elapsed. |
IsDisconnected() bool / DisconnectedAt() time.Time / IsExpired(grace time.Duration) bool |
Introspection used by the Hub's cleanup loop; IsExpired is false while connected (disconnectedAt is zero) and becomes true once time.Since(disconnectedAt) > grace. |
EnqueuePush(msg PushMessage) |
Enqueues a push frame. This is what BaseComponent.Toast/ToastT end up calling, via the pusher callback ws.Session.injectPusher wires into every mounted component. |
Internally, each Session has a buffered outbound channel (32 frames) drained
by the write loop; if the channel is full, enqueue silently drops the frame
rather than blocking (a slow/dead client should not stall the read loop).
3. Hub lifecycle
type Hub struct { /* ... */ }
func NewHub() *Hub
func NewHubWithGracePeriod(grace time.Duration) *Hub
const DefaultGracePeriod = 60 * time.Second
The Hub is the process-wide registry of live Sessions, plus a background
cleanup goroutine.
NewHub()starts a hub withDefaultGracePeriod(defined inws/hub.goas60 * time.Second) and an internal cleanup tick of 10 seconds (defaultCleanupInterval, not currently configurable).NewHubWithGracePeriod(grace time.Duration)lets you override the grace period — mainly intended for tests that want a short grace period to assert cleanup behavior deterministically, but nothing stops you from tuning it in production if 60s doesn't fit your reconnect UX (e.g. a flaky mobile network might want a longer grace period; a low-memory multi-tenant deployment might want a shorter one).
hub := ws.NewHub() // 60s grace period
hub := ws.NewHubWithGracePeriod(10 * time.Second) // custom grace period
Hub methods:
| Method | Purpose |
|---|---|
Register(s *Session) |
Adds a session to the hub's map, keyed by s.ID. Called once per fresh connect (not on reconnect — the session already exists). |
Unregister(sessionID string) |
Removes a session from the map without closing it. Rarely called directly; cleanup uses delete + Close together. |
Get(sessionID string) (*Session, bool) |
Looks up a session by ID — used by the WebSocket handler to find the session for a ?session=<id> reconnect. |
Push(sessionID string, msg PushMessage) error |
Sends one push message to exactly one session by ID. Returns ErrSessionNotFound if the ID isn't registered. |
Broadcast(msg PushMessage) |
Sends the same push message to every currently registered session — this is what admin/notification endpoints use (see the contact-form example's /admin/broadcast route). |
Stop() |
Signals the cleanup goroutine to exit and blocks until it has. Call this on graceful application shutdown if you want the background goroutine to stop cleanly (not required for the process to exit, just tidy). |
Every 10 seconds, the cleanup loop scans all registered sessions; any
session whose IsExpired(gracePeriod) is true (disconnected longer than
the grace period) is removed from the map and has Close() called on it
(unmounting every component it still owned).
4. Reconnect semantics
- Fresh connect:
?component=<name>(no?session=) → registry lookup, newSession,MountComponent,hub.Register,SendSessionFrame,SendInitialRenders, thenRun. - Reconnect:
?session=<id>→hub.Get(id); if found,Reattach(conn)rebinds the live connection (errorErrSessionAlreadyActiveif another connection is already attached — e.g. two tabs racing on the same ID); no newsessionframe is sent (the client already has the ID), butSendInitialRenders()still runs so the DOM catches up to any server-side state changes that happened while disconnected. - Unknown session:
?session=<id>whereidis not in the hub (already expired/cleaned up) → the server writes anerrorframe ("session not found") and closes the connection. The client-side runtime (goui.js) specifically recognizes this message, clears its stored session ID fromsessionStorage, and reconnects fresh with?component=instead.
5. Frame protocol reference
Every message on the WebSocket is one JSON object matching the ws.Frame
struct:
type Frame struct {
Type string `json:"type"`
Component string `json:"component,omitempty"`
Event string `json:"event,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
const (
FrameTypeEvent = "event"
FrameTypeRender = "render"
FrameTypePush = "push"
FrameTypeError = "error"
FrameTypeSession = "session"
FrameTypePrefetch = "prefetch"
FrameTypeActivate = "activate"
)
| Type | Direction | Payload shape | When it is sent |
|---|---|---|---|
event |
client → server | {"component": "<id>", "event": "<name>", "payload": {...}} |
Every g-click/g-change/g-submit/debounced-g-input interaction. Routed to Session.handleEventFrame, which looks up the component by component (its instance ID) and calls component.HandleEvent(ctx, event, payload). |
render |
server → client | []diff.Patch (JSON array of patch objects: op, path, plus op-specific fields like html, text, attr, value, from_idx, to_idx) |
After HandleEvent succeeds without core.ErrSkipRender (incremental patch against the previous tree), on the very first render of a component (full OpReplace), and once per component from SendInitialRenders on every (re)connect. |
push |
server → client | ws.PushMessage — {"kind": "success|error|warning|info", "text": "..."} |
Whenever BaseComponent.Toast/ToastT is called (per-session) or Hub.Broadcast/Hub.Push is called (admin/system notifications). Not tied to any specific component — it's a session-wide notification. |
error |
server → client | ws.ErrorPayload — {"message": "..."} |
Malformed inbound JSON, unknown component ID in an event frame, a HandleEvent/Render/diff error, a failed Reattach, or an unknown/expired ?session= ID on connect. |
session |
server → client | ws.SessionPayload — {"id": "<session-id>"} |
Exactly once, immediately after a fresh connect (never on reconnect). The client persists this ID (sessionStorage) and includes it as ?session= on future reconnect attempts. |
prefetch |
client → server | {"component": "<registry-name>"} (note: Component here holds the registry name, not yet an instance ID) |
Sent by the prefetch.js client module on hover (~100ms) or viewport entry over an element with data-goui-prefetch="<name>". Triggers Session.Prefetch(name) — mounts silently, no render sent back. |
activate |
client → server | {"component": "<registry-name>"} |
Sent by prefetch.js on click of an element with data-goui-activate="<name>". Triggers Session.Activate(name), which promotes the prefetched instance (or mounts fresh if it wasn't prefetched) and immediately sends its first render frame — with a freshly generated component instance ID. |
Notes:
prefetch/activateframes carry the registry name in thecomponentfield (e.g."contact"), whereasevent/renderframes carry a runtime instance ID (a random hex string generated per mounted component). Don't confuse the two — a registry name may be activated into a brand-new instance ID each time.renderpayloads are always an array ofdiff.Patch, even for the very first render of a component (a single-element array with{"op": "replace", "path": [], "html": "...", "tag": "..."}), so the client-side logic for "first render" and "incremental patch" is unified.- Failures inside
HandleEventthat are exactlycore.ErrSkipRender(viaerrors.Is) produce no frame at all — not even anerrorframe. This is intentional for client-owned editors; see07-forms-tier2.md.
6. ws.Server, adapters, and a main.go skeleton
The core module exposes a framework-agnostic acceptor:
type Server struct { /* Hub, Registry, Translator */ }
func NewServer(hub *Hub, registry *core.Registry, translator *i18n.Translator) *Server
// Path is the default WebSocket endpoint path.
const Path = "/goui/ws"
type ConnectParams struct {
SessionID string // ?session=
ComponentName string // ?component=
Locale string // ?locale=
}
func (s *Server) ServeConn(ctx context.Context, conn Conn, p ConnectParams) error
Each adapter performs the HTTP WebSocket upgrade, reads the query parameters
above, and calls ServeConn. You never call ServeConn yourself unless you
are writing a custom adapter.
Register routes through the adapter for your stack (Options.Server is
required; Options.Store is optional — see 11-file-uploads.md):
server := ws.NewServer(hub, registry, tr)
// Fiber
import gouifiber "github.com/zatrano/goui/adapters/fiber"
gouifiber.Register(app, gouifiber.Options{Server: server, Store: store})
// net/http ServeMux
import gouistdlib "github.com/zatrano/goui/adapters/stdlib"
gouistdlib.Register(mux, gouistdlib.Options{Server: server, Store: store})
// Chi (stdlib adapter Mount)
gouistdlib.Mount(chiRouter, gouistdlib.Options{Server: server})
// Gin
import gouigin "github.com/zatrano/goui/adapters/gin"
gouigin.Register(r, gouigin.Options{Server: server, Store: store})
// Echo
import gouiecho "github.com/zatrano/goui/adapters/echo"
gouiecho.Register(e, gouiecho.Options{Server: server, Store: store})
Full skeleton for a Fiber main.go that wires everything together:
package main
import (
"log"
"path/filepath"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/static"
gouifiber "github.com/zatrano/goui/adapters/fiber"
"github.com/zatrano/goui/core"
"github.com/zatrano/goui/i18n"
"github.com/zatrano/goui/upload"
"github.com/zatrano/goui/ws"
)
func main() {
// 1. i18n
tr := i18n.NewTranslator()
_ = tr.LoadLocale("tr", filepath.Join("i18n", "locales", "tr.json"))
_ = tr.LoadLocale("en", filepath.Join("i18n", "locales", "en.json"))
// 2. Component registry
registry := core.NewRegistry()
if err := registry.Register("counter", func() core.Component { return &Counter{} }); err != nil {
log.Fatal(err)
}
// 3. Hub — default 60s grace period, or tune it:
hub := ws.NewHub()
// hub := ws.NewHubWithGracePeriod(30 * time.Second)
server := ws.NewServer(hub, registry, tr)
// 4. Fiber app + static assets
app := fiber.New()
app.Use("/client", static.New("./client"))
app.Get("/", func(c fiber.Ctx) error {
return c.SendFile("index.html")
})
// 5. (optional) file uploads — see 11-file-uploads.md
store, err := upload.NewLocalStore("./.goui-uploads", "/goui/files", 8<<20)
if err != nil {
log.Fatal(err)
}
// 6. GoUI routes (WebSocket + optional upload)
gouifiber.Register(app, gouifiber.Options{Server: server, Store: store})
// 7. (optional) server-initiated push, e.g. an admin broadcast endpoint
app.Get("/admin/broadcast", func(c fiber.Ctx) error {
hub.Broadcast(ws.PushMessage{Kind: "info", Text: c.Query("text", "Hello")})
return c.JSON(fiber.Map{"ok": true})
})
log.Println("listening on http://localhost:3000")
log.Fatal(app.Listen(":3000"))
// On graceful shutdown elsewhere in your code: hub.Stop()
}
See 01-getting-started.md for the Counter
component itself, and 05-forms-tier1.md /
07-forms-tier2.md for what to register in place of
counter in a real application.