godom

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Jun 28, 2026 License: MIT Imports: 17 Imported by: 0

README

godom

Tests Go Report Card Go Reference

Stable and in production use. The public API is settled and powers a real application end-to-end. Still on the 0.x line while the most recently added APIs (computed fields, async tasks, the per-connection client bridge) get more mileage; only minor refinements are expected before 1.0.

godom is a framework for building local apps in Go that use the browser as the UI layer. It is not a web framework — there are no API endpoints, no frontend/backend split, no JavaScript to author for typical use. You build a Go struct, bind HTML to it with directives, and go build gives you a single binary. Run it, and the UI appears in your browser.

The browser is the rendering engine. All state and logic live in your Go process. The JS bridge is a thin command executor that the framework injects. For most apps, you never touch JS — but when you need to integrate a JS library (charts, maps, editors), the plugin system lets you bridge Go data to any JS library.

godom also works as a local network service: run the binary on a headless machine and access the UI from any browser on the network. See docs/why.md for the full rationale and how godom differs from Electron, Tauri, and Wails.

Showcase

Solar System System Monitor (Chart.js)
Solar System System Monitor with Chart.js
3D engine in Go, Canvas 2D rendering Live charts with Chart.js plugin
Terminal Terminal + Claude Code
Terminal Claude Code in browser terminal
Full PTY shell via xterm.js Claude Code running in the browser terminal
package main

import (
    "embed"
    "log"
    "github.com/anupshinde/godom"
)

//go:embed ui
var ui embed.FS

type App struct {
    godom.Island
    Count int
    Step  int
}

func (a *App) Increment() {
    a.Count += a.Step
}

func (a *App) Decrement() {
    a.Count -= a.Step
}

func main() {
    app := &App{Step: 1}
    app.Template = "ui/index.html"

    eng := godom.NewEngine()
    eng.SetFS(ui)
    log.Fatal(eng.QuickServe(app))
}
<!-- ui/index.html -->
<h1><span g-text="Count">0</span></h1>
<button g-click="Decrement">−</button>
<button g-click="Increment">+</button>
<div>
    Step size: <input type="number" min="1" max="100" g-bind="Step"/>
</div>

Run go build and you get a single binary that opens the browser and shows a live counter. The HTML, CSS, and JS bridge are all embedded into the binary via Go's embed package — there are no external files to ship or manage.

How it works

  • Your Go struct holds all application state
  • HTML templates use g-* directives to bind to struct fields and methods
  • A virtual DOM in Go diffs state changes and sends minimal patches via binary WebSocket (Protocol Buffers) — no page reloads
  • State lives in the Go process and survives browser close/reopen — close the tab, reopen it, and you're back where you left off
  • Open the same app in multiple browser tabs and they stay in sync — type in one, see the update in the other. This falls out naturally from the architecture: Go owns the state and pushes DOM patches to every connected tab
  • All directives are validated at startup — typos in field/method names cause log.Fatal, not silent runtime bugs

Install

Use in your project:

go get github.com/anupshinde/godom

Run the examples:

git clone https://github.com/anupshinde/godom.git
cd godom
go run ./examples/counter

Requires Go 1.25+ and a web browser.

Directives reference

Data binding
Directive Example Description
g-text g-text="Name" Set element's text content from a field
g-bind g-bind="InputText" Two-way bind an input's value to a field
g-value g-value="Name" One-way bind an input's value (no sync back to Go)
g-checked g-checked="todo.Done" Bind checkbox checked state
g-show g-show="IsVisible" Toggle display: none when falsy
g-hide g-hide="IsHidden" Toggle display: none when truthy
g-if g-if="HasItems" Exclude element from tree when falsy
g-class:name g-class:done="todo.Done" Add/remove a CSS class conditionally
g-attr:name g-attr:transform="Rotation" Set any HTML/SVG attribute from a field
g-style:prop g-style:width="BarWidth" Set an inline CSS property from a field
g-plugin:name g-plugin:chartjs="MyChart" Send field data to a registered JS plugin
g-shadow g-shadow Render island inside a Shadow DOM for CSS isolation
Events
Directive Example Description
g-click g-click="Save" Call a method on click
g-click g-click="Remove(i)" Call with arguments resolved from context
g-keydown g-keydown="Submit" Call method on every key press
g-keydown g-keydown="Enter:Submit" Call method on specific key press
g-keydown g-keydown="ArrowUp:Up;ArrowDown:Down" Multiple key bindings (semicolon-separated)
g-mousedown g-mousedown="OnDown" Mouse button pressed — method receives (x, y float64)
g-mousemove g-mousemove="OnMove" Mouse moved — throttled to animation frame, receives (x, y float64)
g-mouseup g-mouseup="OnUp" Mouse button released — receives (x, y float64)
g-wheel g-wheel="OnScroll" Scroll wheel — receives (deltaY float64)
Drag and drop
Directive Example Description
g-draggable g-draggable="i" Make element draggable, with the given value as drag data
g-draggable:group g-draggable:palette="'red'" Draggable with a named group — only matching g-drop:group/g-dropzone:group handlers accept the drop
g-drop g-drop="Reorder" Call method on drop — receives (from, to)
g-drop:group g-drop:palette="Add" Drop handler filtered by group — only fires for matching g-draggable:group
g-dropzone g-dropzone="HandleDrop" Synonym for g-drop — registers a drop handler on a "zone" element (often without its own g-draggable, in which case to arrives as "null")

Groups isolate drag interactions. A g-draggable:palette element can only be dropped on a g-drop:palette handler. Without a group, all draggables and drop handlers interact freely.

Drop data is passed as method arguments: from (the draggable's value) and to (the drop target's g-draggable value, or "null" if it has none). Any extra args declared in the directive (g-drop="Reorder(item)") are appended after them. String and numeric values are preserved automatically.

CSS classes are applied automatically during drag operations:

  • .g-dragging — on the element being dragged
  • .g-drag-over — on a drop zone when a compatible draggable hovers over it

See docs/drag-drop.md for the full design rationale — why this split between bridge and Go, how groups are filtered, and alternatives considered.

Lists
<li g-for="todo, i in Todos">
    <span g-text="todo.Text"></span>
    <input type="checkbox" g-checked="todo.Done" g-click="Toggle(i)" />
    <button g-click="Remove(i)">&times;</button>
</li>

g-for="item, index in ListField" repeats the element for each item in a slice field. The index variable is optional (g-for="item in Items" works too).

List rendering uses VDOM diffing — only changed items get DOM updates, new items are appended, removed items are truncated.

Keyed lists

Add g-key to give list items a stable identity for efficient reordering:

<li g-for="todo, i in Todos" g-key="todo.ID">
    <span g-text="todo.Text"></span>
</li>

Without g-key, children are matched positionally. With it, the differ detects inserts, deletes, and moves, producing minimal DOM operations instead of redrawing.

Nested lists

g-for loops can be nested. Inner loops iterate over fields of the outer item:

<div g-for="field, i in Fields">
    <label g-text="field.Label"></label>
    <select g-show="field.IsSelect" style="display:none">
        <option g-for="opt in field.Options" g-text="opt"></option>
    </select>
</div>

The inner g-for resolves field.Options from the outer loop variable. This works to arbitrary nesting depth. See docs/nested-for.md for the design details.

Expressions

Directives support:

  • Field access: FieldName
  • Dotted paths: todo.Text, item.Address.City
  • Loop variables: todo, i from g-for
  • Literals: true, false, integers, quoted strings
  • Text interpolation: {{Name}} in HTML text content (e.g., <p>Hello, {{Name}}!</p>)
  • Async-task bindings: Busy('search'), Progress('search'), Err('search'), Crashed('search') (see Async tasks)

All expressions are resolved in Go (the browser-side bridge is a pure command executor).

Composition

godom has two composition tiers: partials (stateless HTML includes) and islands (stateful, goroutine-backed runtime units). Reach for a partial when you just want shared HTML; reach for an island when you want isolated state and behavior. See docs/why-islands.md for the full explanation.

Partials (template includes)

Split HTML into reusable files. Any HTML file in your embedded filesystem can be used as a custom element:

<!-- ui/todo-item.html -->
<li g-class:done="todo.Done">
    <input type="checkbox" g-checked="todo.Done" g-click="Toggle(index)" />
    <span g-text="todo.Text"></span>
    <button g-click="Remove(index)">&times;</button>
</li>
<!-- ui/index.html -->
<ul>
    <todo-item g-for="todo, i in Todos"></todo-item>
</ul>

Partials are expanded inline at registration time — directives resolve against the enclosing island's state. Loop variables (todo, i) are available inside the included template. Partials carry no state, no goroutine, no lifecycle.

Shared partials via the registry

Partials can also be registered by name, independent of any filesystem — useful for reusable snippets shared across islands.

// Register a raw HTML string as a shared partial:
eng.RegisterPartial("my-badge", `<span class="badge"><g-slot/></span>`)

// Or bulk-register every *.html file from a directory:
//go:embed partials
var partialsFS embed.FS
eng.UsePartials(partialsFS, "partials")   // partials/my-badge.html → <my-badge>

Partial lookup order for <my-tag>:

  1. Island's own FS at the entry template's directory (sibling file).
  2. Engine's partial registry (populated via RegisterPartial / UsePartials).
  3. If neither has it, startup errors with every location searched.
Slotting children with <g-slot/>

A partial can contain a <g-slot/> marker. Whatever sits between a consumer's custom-element tags replaces the slot:

<!-- info-note.html -->
<div class="callout">
    <svg>...icon...</svg>
    <div><g-slot/></div>
</div>
<info-note>
    <p>This content lands inside the callout.</p>
</info-note>

Partials without a <g-slot/> discard any inner content (the default).

Islands

For apps with multiple independent pieces of state, register separate islands. Each gets its own Go struct, HTML template, VDOM tree, goroutine, and refresh cycle. The parent declares insertion points with g-island, and child islands render into them.

eng.SetFS(ui)

// Child islands — set TargetName and Template, then register
counter := &Counter{Step: 1}
counter.TargetName = "counter"
counter.Template = "ui/counter/index.html"
eng.Register(counter)

// Root island owns the page layout (QuickServe auto-sets TargetName to "document.body")
layout := &Layout{}
layout.Template = "ui/layout/index.html"
log.Fatal(eng.QuickServe(layout))

The parent template declares targets with the g-island attribute:

<!-- ui/layout/index.html -->
<body>
    <h1>My App</h1>
    <div class="sidebar" g-island="sidebar"></div>
    <div class="main" g-island="counter"></div>
</body>

Child templates are HTML fragments (no <html>/<head>/<body>) — they render into the parent's target element:

<!-- ui/counter/index.html -->
<div>
    <span g-text="Count">0</span>
    <button g-click="Increment">+</button>
</div>

Register is variadic, so you can register all children in one call:

eng.Register(navbar, toast, sidebar, counter, clock, monitor, ticker, tips)

Cross-island communication uses Go callbacks:

sidebar.OnNavigate = func(msg, kind string) { toast.Show(msg, kind) }
Per-island filesystem (AssetsFS)

For portable tool packages, each island can bring its own filesystem — Go code and HTML colocate in one folder:

// tools/counter/counter.go
//go:embed *.html
var fsys embed.FS

func New() *Counter {
    return &Counter{Island: godom.Island{
        TargetName: "counter",
        Template:   "counter.html",   // path inside counter's own fs
        AssetsFS:   fsys,
    }}
}

Main no longer needs SetFS if every island brings its own. Local sibling partials still work — the lookup searches the island's own FS first.

AssetsFS is fs.FS, so os.DirFS(".") also works for dev-mode edits without rebuilding.

Inline HTML (TemplateHTML)

For tiny one-off islands, skip the filesystem entirely:

const tmpl = `<span class="font-mono" g-text="Time">--:--:--</span>`

func New() *DigiClock {
    return &DigiClock{Island: godom.Island{
        TargetName:   "digiclock",
        TemplateHTML: tmpl,   // no Template, no AssetsFS
    }}
}

Inline-HTML islands can still use shared partials from the registry; they just don't have sibling-file lookup.

See examples/multi-island/ for a full 9-island demo.

API

Engine
eng := godom.NewEngine()                          // Create a new Engine
eng.Port = 8081                                   // Set port (0 = random)
eng.Host = "0.0.0.0"                              // Bind to all interfaces (default "localhost")
eng.NoAuth = true                                 // Disable token auth (default false = auth enabled)
eng.FixedAuthToken = "my-secret"                  // Fixed token (default: random per startup)
eng.NoBrowser = true                              // Don't auto-open browser
eng.Quiet = true                                  // Suppress startup output
eng.DisableExecJS = true                          // Disable ExecJS server-side
eng.Use(chartjs.Plugin, plotly.Plugin)            // Register plugins (Chart.js, Plotly, ECharts, etc.)
eng.RegisterPlugin("myplugin", bridgeJS)          // Register a custom plugin with one or more JS scripts
eng.SetFS(fsys)                                   // Default UI filesystem; optional if every island has AssetsFS
eng.RegisterPartial("my-badge", html)             // Register a shared partial by name (raw string)
eng.UsePartials(fsys, "partials")                 // Bulk-register partials from a directory (scans *.html)
eng.DisconnectHTML = "<div>Custom overlay</div>"  // Custom disconnect overlay (root mode)
eng.DisconnectBadgeHTML = "<span>Offline</span>"  // Custom disconnect badge (embedded mode)

child.TargetName = "name"                         // Matches g-island="name" in parent template
child.Template = "ui/child.html"                  // Template path (resolved against AssetsFS or engine SetFS)
child.AssetsFS = childFS                          // Optional per-island filesystem (overrides SetFS for this island)
// or:
child.TemplateHTML = "<div>...</div>"             // Inline HTML — no filesystem at all
eng.Register(child)                               // Register one or more islands (variadic)

app.Template = "ui/index.html"
log.Fatal(eng.QuickServe(app))                    // Auto-sets TargetName to "document.body", registers, serves, blocks

For developer-owned servers, wire godom into your mux and lifecycle explicitly:

mux := http.NewServeMux()
mux.HandleFunc("/", servePage)

eng.SetMux(mux, &godom.MuxOptions{
    WSPath:     "/app/ws",
    ScriptPath: "/assets/godom.js",
})

eng.SetAuth(myAuthFunc)  // optional

if err := eng.Run(); err != nil {
    log.Fatal(err)
}

log.Fatal(eng.ListenAndServe())

Run() validates templates, registers godom handlers on the mux from SetMux(), and starts island event processors. ListenAndServe() binds the configured host/port, wraps the mux with auth middleware, opens the browser unless disabled, and blocks serving requests. If you manage shutdown yourself, call Cleanup() before exit to stop island goroutines cleanly.

Settings can also be set via environment variables before NewEngine() runs:

GODOM_PORT=8081 GODOM_HOST=0.0.0.0 GODOM_DEBUG=true ./myapp

Boolean env vars (GODOM_DEBUG, GODOM_NO_AUTH, GODOM_NO_BROWSER, GODOM_QUIET) accept any value recognized by Go's strconv.ParseBool: 1, t, true, TRUE, 0, f, false, FALSE, etc.

NewEngine() reads env vars into the engine's initial state; any field you set in code after NewEngine() overrides the env-derived value. GODOM_DEBUG is server-side only: it enables debug logging and bridge warnings, but it is not an Engine field. godom does not parse CLI flags, so your binary owns its flags entirely.

For external hosting (embedding godom islands in pages not served by godom), set browser-side variables before loading the bundle:

<script>
window.GODOM_WS_URL = "ws://localhost:9091/ws";  // Connect to godom on a different origin
window.GODOM_NS = "myApp";                        // Rename window.godom to window.myApp
</script>
<script src="http://localhost:9091/godom.js"></script>

See docs/configuration.md for the full reference on settings, environment variables, authentication, and precedence rules.

Island

Embed godom.Island in your struct. TargetName matches g-island="name" attributes in parent templates. The island's HTML comes from one of three sources:

Field Use when
Template + engine's SetFS(fs) Flat single-FS apps. Template is resolved against the engine-wide FS.
Template + AssetsFS (per-island) Tool packages that ship HTML colocated with Go code. Each island brings its own //go:embed. Local sibling partials resolve from this FS automatically.
TemplateHTML (inline) Tiny islands with no partials — the template is a plain Go string literal.

AssetsFS is fs.FS — so embed.FS, os.DirFS (runtime edits), fstest.MapFS, or any custom filesystem all work.

type MyApp struct {
    godom.Island            // TargetName, Template, TemplateHTML, AssetsFS all live here
    Name string        // exported fields = state
    Items []Item       // slices work with g-for
}

func (a *MyApp) DoSomething() {
    // exported methods = event handlers
    // mutate fields directly, framework handles sync
}
Refresh

Push state to all connected browsers from a background goroutine:

func (a *App) monitor() {
    for {
        time.Sleep(1 * time.Second)
        a.Value = readSensor()
        a.Refresh()  // broadcast to all browsers
    }
}

Call Refresh() after mutating fields outside of user-triggered events (clicks, input). This is how you build dashboards, monitors, and live-updating UIs.

MarkRefresh (surgical updates)

For large UIs where only a few fields changed, mark specific fields for surgical refresh:

func (a *App) UpdatePrice(i int) {
    a.Stocks[i].Price = fetchPrice()
    a.MarkRefresh("Stocks")  // only rebuild nodes bound to Stocks
    a.Refresh()
}

This avoids a full tree diff — only the nodes bound to the marked fields are rebuilt and patched.

ExecJS

Run a JavaScript expression in each connected browser and receive each result asynchronously:

a.ExecJS("location.pathname", func(result []byte, err string) {
    if err != "" {
        log.Println("exec error:", err)
        return
    }
    log.Printf("browser returned: %s", result)
})

In a native godom app you rarely need this — godom renders the DOM for you, so it's mainly for browser-only capabilities (viewport, clipboard) or driving an imperative third-party library. Its broadcast nature is most useful in injection scenarios (godom injected into a third-party page), where reaching into the host page's DOM/JS is often the only way. It can be disabled on the server with eng.DisableExecJS = true and on the page with window.GODOM_DISABLE_EXEC = true.

To run JS on one tab instead of broadcasting, use Client.Eval / Client.Call — see Connections and targeted JS below.

Computed fields

Declare a field as derived from others; the engine recomputes and surgically patches it when a dependency changes — no manual re-marking:

cart.Compute("Subtotal", func() any { return cart.Qty * cart.UnitPrice }, "Qty", "UnitPrice")
// call before eng.Register(cart); deps/cycles validated at Register
Async tasks

Managed background work, safe by construction — the closure runs off the event loop and marshals state back via t.Apply:

func (v *View) Search() {
    v.Task("search", func(t *godom.Task) {
        rows, err := query(v.Q)              // off-loop; safe to block
        if err != nil { t.Fail(err); return }
        t.Apply(func() { v.Rows = rows; v.MarkRefresh("Rows") })
    })
}

Pending/progress/error bind without an app field: g-attr:disabled="Busy('search')", g-text="Progress('search')". Re-entry policy via WithRestart() / WithQueue(); panics fail the task, not the process. See the guide.

Connections and targeted JS

A *godom.Client is one connection — one running page instance (usually a tab, but also a window, iframe, or another device; per-socket, so a reconnect is a new one). eng.Clients() is the live roster; each has c.Env() (timezone/locale/viewport, seedable via an OnConnect hook) and targeted JS. Register a client-side module and call it on specific connections with typed args:

eng.RegisterClientModule("widget", widgetJS) // window.godom.modules.widget
for _, c := range eng.ClientsWith("widget") { // only connections that declared the capability
    c.CallAsync("widget.render", data, nil)
}

⚠️ Targeting one connection deliberately steps outside godom's cross-connection sync (it acts on the client-side-JS layer godom doesn't replicate — the Go VDOM itself stays broadcast). Note that, unlike page-scoped VDOM patches, a broadcast ExecJS runs on every connection — which is why per-connection addressing exists. Use it for per-connection concerns: fan out via ClientsWith for a replicated widget (cleaner than a self-guarded broadcast), and target the one owner of a privileged link (e.g. a single tab holding a live external-app session — where targeting is necessary, not just nicer).

See the guide and the AI reference.

Plugins

Integrate JS libraries (charts, maps, editors) without authoring JS yourself. A plugin is a thin JS adapter that receives Go data via the g-plugin:name directive:

<canvas g-plugin:chartjs="MyChart"></canvas>

Shipped plugins are registered with eng.Use():

eng.Use(chartjs.Plugin)   // Chart.js — line, bar, pie, doughnut
eng.Use(plotly.Plugin)    // Plotly — scatter, bar, heatmaps, dual-axis
eng.Use(echarts.Plugin)   // ECharts — line, bar, pie, candlestick, geo

For custom/one-off integrations, use the lower-level eng.RegisterPlugin(name, scripts...) to register JS adapters directly. The plugin JS calls godom.register(name, {init, update}) to handle data from Go. Scripts are injected in order — typically the library first, then the bridge.

See docs/plugins.md for the full list and docs/javascript-libraries.md for a guide on using any JS library — with or without a plugin package.

Examples

  • examples/counter/ — minimal example (the one shown above)
  • examples/progress-bar/ — animated progress bar with Refresh() and g-style:width from a goroutine
  • examples/clock/ — analog clock with Refresh() and g-attr (server-pushed updates)
  • examples/todolist/ — template includes with prop passing
  • examples/sync-demo/ — multi-tab state sync demonstration
  • examples/system-monitor/ — live system monitor dashboard with Refresh(), g-attr, and template includes
  • examples/system-monitor-chartjs/ — system monitor with Chart.js plugin (CPU, memory, disk, swap, load charts)
  • examples/charts-without-plugin/ — ApexCharts with inline bridge adapter (no plugin package)
  • examples/drag-tiles/ — 24 colored tiles with drag-to-reorder and a periodic shine animation sweep
  • examples/drag-demo/ — drag-and-drop demo with groups, dropzones, and string data (palette → canvas → trash)
  • examples/basic-form-builder/ — drag-and-drop form builder with palette, canvas, config panel, preview mode, and JSON export (uses drag groups, nested g-for, conditional rendering)
  • examples/stock-ticker/ — live stock ticker dashboard with 30 simulated stocks, per-stock tick intervals, table with color-coded gainers/losers, and external CSS via static file serving
  • examples/solar-system/ — 3D solar system with a Go-built 3D engine and Canvas 2D rendering (mouse drag, scroll zoom, follow planets)
  • examples/terminal/ — browser-based terminal with full shell access via PTY and xterm.js (session respawn, resize, multi-tab, Tailscale-friendly)
  • examples/multi-page-v2/reference for Phase B features — tool packages with //go:embed + AssetsFS, inline TemplateHTML, shared partials via UsePartials / RegisterPartial, <g-slot/> children substitution, os.DirFS dev mode, multi-page routing with Go html/template chrome
  • examples/multi-island/ — 9-island dashboard with g-island composition, cross-island callbacks, Chart.js plugin, drag-and-drop reorder, goroutine-driven updates
  • examples/embedded-widget/ — godom islands embedded in an external HTML page (separate static server, GODOM_WS_URL, /godom.js script tag, g-island targets, g-shadow for CSS isolation)
  • examples/same-island-repeated/ — same island type rendered into multiple g-island targets simultaneously
  • examples/video-player/ — video player with Go decoding frames via ffmpeg and rendering on canvas
  • examples/breakout-game/ — breakout game with Go-side physics, canvas rendering, keyboard input, and collision detection
  • examples/chart-plugins/ — Plotly and ECharts plugins side by side with live-updating charts
  • examples/crash-test/ — intentionally crashes after startup to exercise the disconnect UI
  • examples/markdown-editor/ — two-pane markdown editor with live preview and plain JS scroll sync
  • examples/multi-page/ — multi-page app with developer-owned mux and routing between pages
  • examples/select-test/ — focused repro app for select/input sync behavior
  • examples/shared-state/ — shared state between islands via embedded struct pointers
  • examples/dynamic-mount/ — dynamic island mounting and unmounting via godom.mount()
  • examples/exec-and-call/ — ExecJS (Go→browser) and godom.call() (browser→Go) demo
  • examples/ws-lifecycle/ — WebSocket lifecycle hooks (onconnect, ondisconnect, onerror)

After cloning the repo (see Install), run any example with:

go run ./examples/counter

The system-monitor, system-monitor-chartjs, and terminal examples have their own go.mod (for platform-specific or extra dependencies), so run them from their directory:

cd examples/system-monitor && go run .
cd examples/system-monitor-chartjs && go run .
cd examples/terminal && go run .

This starts the server and opens your browser. To build a standalone binary instead:

go build -o counter ./examples/counter
./counter

Browser extension

godom includes a Chrome extension that injects godom.js into any website, letting your Go app enhance pages you don't control. Configure URL patterns to decide which pages get injection, and a sidebar panel renders your godom island alongside the host page.

  • Configurable include/exclude URL patterns per rule with enable/disable toggles
  • Resizable sidebar panel with CSS isolation (g-shadow), maximize/restore, and page-split layout
  • Sidebar state persists across page navigations within the same site
  • Works with named islands — the sidebar renders a g-island of your choice (default: extension)
  • Root mode (document.body) is blocked by default to prevent replacing the host page
  • Hide badge per rule for screen recordings and demos
  • Export/import rules as JSON for sharing across machines
  • Cross-machine support via HTTPS reverse proxy (e.g. Caddy)

See browser_extension/README.md for installation and configuration.

Design principles

  • Minimal JavaScript — the JS bridge is injected automatically. For most apps, you write zero JS. When you need a JS library (charts, maps, editors), the plugin system bridges Go data to it with a thin adapter. For purely browser-side micro-interactions (scroll sync, focus, animations), a plain <script> tag in your template works — see docs/javascript-libraries.md
  • Thin bridge — the JS bridge builds the DOM from a tree description on init and applies minimal patches on updates. It does not evaluate expressions, resolve data, diff state, or make decisions. Go builds a virtual DOM tree, diffs it, and sends patches as binary Protocol Buffers over WebSocket. This means all logic is testable in Go, the bridge stays in sync with framework semantics, and debugging stays in one language. Plugins extend the bridge to delegate rendering to JS libraries when needed. g-bind fires on every keystroke with no debounce, keeping two-way binding instant (see docs/transport.md for why this matters)
  • State in Go — the browser is a rendering engine, not the source of truth
  • Fail fast — all directives validated at startup against your struct
  • Single binarygo build produces one executable, no node_modules
  • Local apps — designed for local use and trusted networks, not the public internet. Token-based auth is on by default to prevent other local users from accessing your app. No HTTPS, no deployment ceremony. Also runs as a service on headless machines (why?)

AI disclosure

This project was coded with the help of Claude (Anthropic). The architecture, design decisions, and all code were produced through human-AI collaboration using Claude Code.

The documentation including this README is also maintained by AI.

See docs/AI_USAGE.md for the full philosophy on how AI was used, what has and hasn't been reviewed, and what that means if you use this project.

Documentation

License

MIT — see LICENSE.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Client added in v0.2.0

type Client = server.Client

Client is an addressable handle to one connected browser tab (one WebSocket). It is the foundation that per-connection features build on. A Client is per-socket: a reconnecting tab is a new Client with a new ID. Obtain Clients from Engine.Clients(); they are never constructed by application code.

type Engine added in v0.2.0

type Engine struct {
	Port                int    // 0 = random available port
	Host                string // default "localhost"; set to "0.0.0.0" for network access
	NoAuth              bool   // disable token auth (default false = auth enabled)
	FixedAuthToken      string // fixed auth token; empty = generate random token
	NoBrowser           bool   // don't open browser on start
	Quiet               bool   // suppress startup output
	DisableExecJS       bool   // disable ExecJS — server won't send, bridge won't execute
	DisconnectHTML      string // custom disconnect overlay HTML (root mode); empty = default
	DisconnectBadgeHTML string // custom disconnect badge HTML (embedded mode); empty = default
	// contains filtered or unexported fields
}

Engine is the godom runtime. It registers islands and plugins, mounts the root island, and starts the server.

func NewEngine added in v0.2.0

func NewEngine() *Engine

NewEngine creates a new godom Engine.

func (*Engine) Auth added in v0.2.0

func (a *Engine) Auth() middleware.AuthFunc

func (*Engine) AuthMiddleware added in v0.2.0

func (a *Engine) AuthMiddleware(next http.Handler) http.Handler

AuthMiddleware wraps an http.Handler with the configured auth function. If no auth is configured, returns the handler unwrapped. Must be called after Run().

func (*Engine) BindClients added in v0.2.0

func (a *Engine) BindClients(cs server.ClientSource)

BindClients is called by the server at startup to hand the engine the live connection roster. It is part of the internal EngineConfig wiring and is not intended for application use.

func (*Engine) Cleanup added in v0.2.0

func (a *Engine) Cleanup()

Cleanup closes event channels so island goroutines exit. Call this when your server is shutting down.

func (*Engine) ClientModules added in v0.2.0

func (a *Engine) ClientModules() map[string]string

ClientModules returns the registered client modules. Part of the internal EngineConfig wiring; not intended for application use.

func (*Engine) Clients added in v0.2.0

func (a *Engine) Clients() []*Client

Clients returns a snapshot of the currently connected browser tabs. It returns nil before Run() has started the server. The returned slice is a fresh copy; the *Client values are stable per-connection handles safe to use as map keys.

func (*Engine) ClientsWith added in v0.2.0

func (a *Engine) ClientsWith(capability string) []*Client

ClientsWith returns the connected clients that have advertised the named module capability (via godom.declareCapability in the module's JS). Use it to fan a module call out only to the tabs that can actually handle it — keeping a replicated widget in sync without erroring on tabs that lack the module's prerequisites, or to find the single owner of a privileged capability.

func (*Engine) EmbeddedJS added in v0.2.0

func (a *Engine) EmbeddedJS() (string, string, string)

func (*Engine) ExecJSDisabled added in v0.2.0

func (a *Engine) ExecJSDisabled() bool

func (*Engine) GetDisconnectBadgeHTML added in v0.2.0

func (a *Engine) GetDisconnectBadgeHTML() string

func (*Engine) GetDisconnectHTML added in v0.2.0

func (a *Engine) GetDisconnectHTML() string

func (*Engine) GetFaviconSVG added in v0.2.0

func (a *Engine) GetFaviconSVG() string

func (*Engine) GodomScriptPath added in v0.2.0

func (a *Engine) GodomScriptPath() string

func (*Engine) Islands added in v0.2.0

func (a *Engine) Islands() []*island.Info

func (*Engine) ListenAndServe added in v0.2.0

func (a *Engine) ListenAndServe() error

ListenAndServe binds a port using the startup config (Port, Host), wraps the mux with auth middleware if enabled, prints the URL, opens the browser, and serves. Must be called after Run().

func (*Engine) Mux added in v0.2.0

func (a *Engine) Mux() *http.ServeMux

func (*Engine) PluginScripts added in v0.2.0

func (a *Engine) PluginScripts() map[string][]string

func (*Engine) QuickServe added in v0.2.0

func (a *Engine) QuickServe(isl interface{}) error

QuickServe is the convenience path for single-island apps. It registers the island as the root (document.body), creates a minimal page, sets up the mux, and serves. The island must have Template set before calling.

Example:

app := &App{Step: 1}
app.Template = "ui/index.html"
eng := godom.NewEngine()
eng.SetFS(ui)
log.Fatal(eng.QuickServe(app))

func (*Engine) Register added in v0.2.0

func (a *Engine) Register(islands ...interface{})

Register registers one or more islands. Each island must set TargetName and one of: TemplateHTML (inline), or Template+AssetsFS, or just Template with a shared SetFS() default on the engine.

func (*Engine) RegisterClientModule added in v0.2.0

func (a *Engine) RegisterClientModule(name, js string)

RegisterClientModule ships a client-side JS module to every browser, exposed as window.godom.modules.<name>. The module script is responsible for assigning itself, e.g. `godom.modules.widget = { render: function(args){ ... } };`. Call a module function with typed args/reply via Client.Call / Client.CallAsync.

Targeting is explicit and the consumer's responsibility: use eng.Clients()-based fan-out to keep a replicated widget in sync across tabs, or call a single client for a singleton owner. Module state is not part of godom's VDOM sync.

func (*Engine) RegisterPartial added in v0.2.0

func (a *Engine) RegisterPartial(name, html string)

RegisterPartial registers a shared partial by name. When a template uses <my-button>, the engine resolves it by looking in (1) the island's local FS at the entry's baseDir, then (2) this registry. Use this for reusable HTML fragments shared across islands.

Example:

eng.RegisterPartial("brand-logo", `<img src="/logo.svg" alt="brand"/>`)

func (*Engine) RegisterPlugin added in v0.2.0

func (a *Engine) RegisterPlugin(name string, scripts ...string)

RegisterPlugin registers a named plugin with one or more JS scripts.

func (*Engine) Run added in v0.2.0

func (a *Engine) Run() error

Run initializes the island lifecycle, registers /ws and /godom.js handlers on the mux set via SetMux, and starts event processors. If GODOM_VALIDATE_ONLY=1 is set, Run() returns immediately after validation succeeds — useful for CI and pre-commit checks.

func (*Engine) SetAuth added in v0.2.0

func (a *Engine) SetAuth(fn middleware.AuthFunc)

SetAuth sets a custom auth function. When set, godom uses it to protect /ws and (via AuthMiddleware/ListenAndServe) all routes. If not set and NoAuth is false, godom uses built-in token auth.

func (*Engine) SetFS added in v0.2.0

func (a *Engine) SetFS(fsys fs.FS)

SetFS sets the default UI filesystem for island templates. It is used by Register() when an island does not carry its own AssetsFS. Optional — if every island brings its own AssetsFS or uses TemplateHTML, SetFS can be skipped.

func (*Engine) SetMux added in v0.2.0

func (a *Engine) SetMux(mux *http.ServeMux, opts *MuxOptions)

SetMux sets the HTTP mux. godom registers its handlers (/ws, /godom.js) on it. Must be called before Run().

func (*Engine) Use added in v0.2.0

func (a *Engine) Use(plugins ...PluginFunc)

Use registers one or more plugins with the engine.

func (*Engine) UsePartials added in v0.2.0

func (a *Engine) UsePartials(fsys fs.FS, baseDir string)

UsePartials bulk-registers partials from a filesystem. It scans baseDir inside fsys for *.html files and calls RegisterPartial(basename, content) for each. Sugar over RegisterPartial for the embed-a-directory case.

Example:

//go:embed partials
var partials embed.FS
eng.UsePartials(partials, "partials")

func (*Engine) WebSocketPath added in v0.2.0

func (a *Engine) WebSocketPath() string

type Env added in v0.2.0

type Env = server.Env

Env is a connection's browser environment (timezone, locale, viewport) — the data Go cannot derive on its own. Read it via Client.Env().

type EnvAware added in v0.2.0

type EnvAware = server.EnvAware

EnvAware is the optional interface an island implements to seed state from a new connection's environment. OnConnect(c *Client) runs once per connecting client, as an ordinary event on the island loop. Seeding a shared field from c.Env() is last-writer-wins across clients — correct for the single-environment case; for divergent per-client environments, scope by page or engine.

type Island added in v0.2.0

type Island struct {
	TargetName   string // matches g-island="name" attributes in parent templates
	Template     string // template path (resolved against AssetsFS or engine's SetFS)
	TemplateHTML string // inline HTML; mutually exclusive with Template/AssetsFS
	AssetsFS     fs.FS  // per-island filesystem for Template + sibling partials
	// contains filtered or unexported fields
}

Island is embedded in user structs to make them godom islands. An island is a self-contained, stateful unit: its own goroutine, event queue, and VDOM tree. This is islands-architecture terminology — what other frameworks call "component" at the page level. See docs/why-islands.md.

Template sources, in order of precedence:

  • TemplateHTML set: inline HTML is used directly (no filesystem involved).
  • Template + AssetsFS set: read Template from AssetsFS.
  • Template + engine's SetFS: read Template from the engine's shared FS.

func (*Island) Compute added in v0.2.0

func (c *Island) Compute(name string, fn func() any, deps ...string)

Compute declares a computed field: Name is one of the island's own exported fields whose value the engine maintains by calling fn — a pure derivation of other fields — whenever any dep changes. The engine assigns fn's result to the field and surgically patches its bound nodes; fn itself must be cheap, pure, non-blocking, and must not mutate island state (do heavy or effectful work in a Task that writes a plain field the computed then reads).

Call Compute before Register (e.g. in a constructor or Init). Dependencies and the dependency graph are validated at Register: an unknown field, a duplicate computed, or a cycle aborts startup.

Example:

c.Compute("Subtotal", func() any { return c.Qty * c.UnitPrice }, "Qty", "UnitPrice")
c.Compute("SubtotalText", func() any { return money(c.Subtotal) }, "Subtotal")

func (*Island) ExecJS added in v0.2.0

func (c *Island) ExecJS(expr string, cb func(result []byte, err string))

ExecJS sends a JavaScript expression to all connected browsers for execution. The callback fires once per connected browser with the JSON-encoded result and an error string (empty on success).

Example:

c.ExecJS("location.pathname", func(result json.RawMessage, err string) {
    var path string
    json.Unmarshal(result, &path)
})

func (*Island) MarkRefresh added in v0.2.0

func (c *Island) MarkRefresh(fields ...string)

MarkRefresh marks fields for surgical refresh. The actual refresh happens when Refresh() is called (either by the user or automatically by the framework after a method call). Multiple calls accumulate.

func (*Island) Refresh added in v0.2.0

func (c *Island) Refresh()

Refresh pushes updates to all connected browsers. If fields were marked via MarkRefresh(), only those bound nodes are patched. Otherwise, a full refresh is sent.

Do not call Refresh inside methods invoked by browser events (e.g. g-click). The framework automatically refreshes after every method call, so calling Refresh there would result in a redundant double invocation. Use Refresh only from background goroutines (timers, tickers, async work).

func (*Island) Task added in v0.2.0

func (c *Island) Task(name string, fn func(*Task), opts ...TaskOption)

Task starts a named background task. The closure fn runs on a fresh goroutine off the island event loop, so it may block on I/O or compute. It must not write island fields directly — marshal every state change back with t.Apply, which runs on the loop and triggers a refresh. Pending/progress/error are bindable without app fields via Busy(name), Progress(name), Err(name), and Crashed(name).

Re-entry: by default a start is dropped while a task of the same name runs; use WithRestart() or WithQueue() to change that. Panics in the task body or its Apply closures are recovered and surfaced as Err(name)/Crashed(name) — they fail the task, not the process.

type MuxOptions added in v0.2.0

type MuxOptions struct {
	WSPath     string // WebSocket endpoint path (default "/ws")
	ScriptPath string // godom.js script path (default "/godom.js")
}

MuxOptions configures custom paths for godom's handlers when using SetMux.

type PluginFunc added in v0.2.0

type PluginFunc func(*Engine)

PluginFunc sets up a plugin on an Engine.

type Task added in v0.2.0

type Task = island.Task

Task is the handle passed to a task closure. The closure runs off the island event loop and must not touch island state directly; all state changes go through Task.Apply, which serializes them on the loop with renders. Cancellation is cooperative via Task.Cancelled / Task.Context.

type TaskOption added in v0.2.0

type TaskOption = island.TaskOption

TaskOption configures a Task start (WithRestart, WithQueue).

func WithQueue added in v0.2.0

func WithQueue() TaskOption

WithQueue runs the new task after the current one of the same name finishes, instead of the default (drop the new start while one is running).

func WithRestart added in v0.2.0

func WithRestart() TaskOption

WithRestart cancels an in-flight task of the same name and starts fresh. The superseded run's late results are fenced out and can never clobber the new run.

type TaskPanic added in v0.2.0

type TaskPanic = island.TaskPanic

TaskPanic is the error type surfaced via the Err(name) binding when a task body or one of its Apply closures panics. Crashed(name) reports it too.

type Viewport added in v0.2.0

type Viewport = server.Viewport

Viewport is the browser viewport size in CSS pixels.

Directories

Path Synopsis
examples
async-tasks command
Async tasks — managed background work, safe by construction.
Async tasks — managed background work, safe by construction.
breakout-game command
chart-plugins command
clock command
computed-fields command
Computed fields — derived values the engine keeps in sync for you.
Computed fields — derived values the engine keeps in sync for you.
counter command
crash-test command
drag-demo command
drag-tiles command
dynamic-mount command
embedded-widget command
exec-and-call command
multi-island command
multi-page command
multi-page-v2 command
multi-page-v2: demonstrates multiple tool islands (counter, clock, monitor, solar system) organized as separate Go packages, mounted on per-tool pages and a combined dashboard page.
multi-page-v2: demonstrates multiple tool islands (counter, clock, monitor, solar system) organized as separate Go packages, mounted on per-tool pages and a combined dashboard page.
multi-page-v2/tools/digiclock
Package digiclock is a minimal digital clock island.
Package digiclock is a minimal digital clock island.
multi-page-v2/tools/solar
Package solar is a 3D solar-system island: Go builds per-frame draw commands, a small canvas2D plugin on the bridge paints them.
Package solar is a 3D solar-system island: Go builds per-frame draw commands, a small canvas2D plugin on the bridge paints them.
progress-bar command
select-test command
shared-state command
solar-system command
stock-ticker command
sync-demo command
todolist command
video-player command
ws-lifecycle command
internal
env
vdom
Package vdom implements a virtual DOM tree with diffing and patching.
Package vdom implements a virtual DOM tree with diffing and patching.
plugins
chartjs
Package chartjs provides a godom plugin for Chart.js integration.
Package chartjs provides a godom plugin for Chart.js integration.
echarts
Package echarts provides a godom plugin for Apache ECharts integration.
Package echarts provides a godom plugin for Apache ECharts integration.
plotly
Package plotly provides a godom plugin for Plotly.js integration.
Package plotly provides a godom plugin for Plotly.js integration.

Jump to

Keyboard shortcuts

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