Go SDK
Complete guide to building Hypen servers with Go
Go SDK
The Go SDK (github.com/hypen-space/core) provides a server-side Hypen implementation for Go applications with reactive state management, routing, cross-module communication, and Remote UI streaming over WebSocket.
Installation
go get github.com/hypen-lang/hypen-golangQuick Start
Create a server that streams a counter UI over WebSocket:
package main
import (
"fmt"
"github.com/hypen-lang/hypen-golang/core"
"github.com/hypen-lang/hypen-golang/remote"
)
func main() {
// 1. Define the module
counter := core.NewAppBuilder(map[string]any{"count": 0}, nil).
OnAction("increment", func(ctx core.ActionHandlerContext) {
count := ctx.State.Get("count").(int)
ctx.State.Set("count", count+1)
}).
OnAction("decrement", func(ctx core.ActionHandlerContext) {
count := ctx.State.Get("count").(int)
ctx.State.Set("count", count-1)
}).
Build()
// 2. Start the server
server := remote.NewRemoteServer().
Module("Counter", &remote.ModuleConfig{
Name: "Counter",
InitialState: map[string]any{"count": 0},
}).
UI(`
Column {
Text("Count: ${state.count}")
.fontSize(32)
.fontWeight("bold")
Row {
Button { Text("-") }
.onClick(@actions.decrement)
.padding(16)
Button { Text("+") }
.onClick(@actions.increment)
.padding(16)
}
.gap(16)
}
.padding(32)
`).
OnConnection(func(client *remote.Client) {
fmt.Printf("Connected: %s\n", client.ID)
}).
OnDisconnection(func(client *remote.Client) {
fmt.Printf("Disconnected: %s\n", client.ID)
}).
Listen(3000)
defer server.Stop()
fmt.Println("Server running on ws://localhost:3000")
select {} // Block forever
}Any Hypen client (web, Android, iOS) can connect to ws://localhost:3000 and render this UI. See the Remote App Tutorial for connecting clients.
Serve() Helper
For simpler cases, use the Serve() convenience function:
server := remote.Serve(remote.ServeOptions{
ModuleName: "Counter",
InitialState: map[string]any{"count": 0},
UI: `
Column {
Text("Count: ${state.count}")
Button { Text("+1") }
.onClick(@actions.increment)
}
`,
Port: 3000,
OnAction: func(action string, payload any, state map[string]any) map[string]any {
if action == "increment" {
state["count"] = state["count"].(int) + 1
}
return state
},
})
defer server.Stop()App Builder API
Modules are defined using the chainable AppBuilder. Every method returns the builder for chaining:
module := core.NewAppBuilder(initialState, options).
OnCreated(createdHandler).
OnAction("name", actionHandler).
OnDestroyed(destroyedHandler).
OnError(errorHandler).
Build()NewAppBuilder
func NewAppBuilder(initialState map[string]any, options *ModuleOptions, app ...*HypenApp) *AppBuilderinitialState— initial state map (required)options— module configuration (can benil)app— optionalHypenAppregistry for named module registration
OnCreated
Lifecycle hook called when the module is initialized:
.OnCreated(func(state *core.ObservableState, ctx core.GlobalContext) {
// Fetch initial data
data, _ := fetchData()
state.Set("items", data)
// Access router
if ctx != nil {
router := ctx.GetRouter()
router.Push("/home")
}
})OnAction
Registers a handler for a named action dispatched from the UI:
.OnAction("submit", func(ctx core.ActionHandlerContext) {
// ctx.Action.Name - the action name
// ctx.Action.Payload - data from the UI (any)
// ctx.Action.Sender - component that dispatched the action
// ctx.State - the current ObservableState (mutable)
// ctx.Context - GlobalContext for cross-module access
payload := ctx.Action.Payload.(map[string]any)
ctx.State.Set("submitted", true)
})OnDestroyed
Lifecycle hook called when the module is unmounted:
.OnDestroyed(func(state *core.ObservableState, ctx core.GlobalContext) {
// Cleanup resources
fmt.Println("Module destroyed")
})OnError
Catches errors (panics) from action handlers and lifecycle hooks:
.OnError(func(ctx core.ErrorContext) *core.ErrorHandlerResult {
if ctx.ActionName != "" {
fmt.Printf("Action %q failed: %v\n", ctx.ActionName, ctx.Error)
} else if ctx.Lifecycle != "" {
fmt.Printf("Lifecycle %q failed: %v\n", ctx.Lifecycle, ctx.Error)
}
return &core.ErrorHandlerResult{Handled: true}
})The ErrorContext fields:
| Field | Type | Description |
|---|---|---|
Error | error | The error or recovered panic |
State | *ObservableState | Current state (for inspection) |
ActionName | string | Set if error came from an action handler |
Lifecycle | string | "created" or "destroyed" if from a lifecycle hook |
Return values:
| Return | Effect |
|---|---|
nil | Default: log and emit global error event |
{Handled: true} | Suppress the error |
{Rethrow: true} | Re-panic the error |
Build
Finalizes the module definition:
module := core.NewAppBuilder(initialState, nil).
OnAction("increment", handler).
Build()
// Returns *ModuleDefinitionState Management
The SDK provides reactive ObservableState with automatic change tracking. State is accessed through dot-separated paths.
Creating State
state := core.NewObservableState(map[string]any{
"user": map[string]any{
"name": "Alice",
"email": "alice@example.com",
},
"items": []any{},
"loading": false,
}, &core.StateObserverOptions{
OnChange: func(change core.StateChange) {
fmt.Printf("Changed paths: %v\n", change.Paths)
},
})Reading State
// Get a value at a dot-separated path
name := state.Get("user.name").(string) // "Alice"
email := state.Get("user.email").(string) // "alice@example.com"
// Get entire state as a deep clone
all := state.GetAll() // map[string]any{...}
// Get a deep snapshot
snapshot := state.Snapshot()Writing State
// Set a value (triggers OnChange)
state.Set("user.name", "Bob")
// Set a nested path
state.Set("user.profile.avatar", "https://...")
// Replace entire state
state.SetAll(map[string]any{
"user": map[string]any{"name": "Charlie"},
"items": []any{},
})
// Delete a key
state.Delete("user.email")Batch Updates
Group multiple changes into a single notification:
// Using BatchUpdate helper
core.BatchStateUpdates(state, func() {
state.Set("user.name", "Dave")
state.Set("user.email", "dave@example.com")
state.Set("loading", false)
})
// Single OnChange notification with all paths
// Or manually
state.BeginBatch()
state.Set("a", 1)
state.Set("b", 2)
state.Set("c", 3)
state.EndBatch() // Triggers single notificationChange Tracking
type StateChange struct {
Paths []StatePath // Changed paths (e.g., ["user.name", "loading"])
NewValues map[StatePath]any // Map of path → new value
}Utility Functions
// Deep clone state
clone := core.GetStateSnapshot(state)
// Deep clone any value
value := core.DeepCloneAny(original)Action Handlers
Action handlers respond to UI interactions and modify state.
Handler Context
type ActionHandlerContext struct {
Action ActionContext
State *ObservableState
Context GlobalContext
}
type ActionContext struct {
Name string // Action name
Payload any // Data from the UI
Sender string // Component ID that dispatched the action
}Examples
Simple handler:
.OnAction("increment", func(ctx core.ActionHandlerContext) {
count := ctx.State.Get("count").(int)
ctx.State.Set("count", count+1)
})With payload:
.OnAction("deleteItem", func(ctx core.ActionHandlerContext) {
payload := ctx.Action.Payload.(map[string]any)
itemID := payload["id"].(string)
items := ctx.State.Get("items").([]any)
filtered := make([]any, 0)
for _, item := range items {
if item.(map[string]any)["id"].(string) != itemID {
filtered = append(filtered, item)
}
}
ctx.State.Set("items", filtered)
})With navigation:
.OnAction("login", func(ctx core.ActionHandlerContext) {
payload := ctx.Action.Payload.(map[string]any)
success := authenticate(payload["email"].(string), payload["password"].(string))
if success {
ctx.State.Set("isLoggedIn", true)
if ctx.Context != nil {
ctx.Context.GetRouter().Push("/dashboard")
}
} else {
ctx.State.Set("error", "Invalid credentials")
}
})Named Modules
Register modules by name using HypenApp for component-based routing:
app := &core.HypenApp{}
// Register via Module helper
homePage := app.Module("HomePage").
DefineState(map[string]any{"title": "Home"}, nil).
OnCreated(func(state *core.ObservableState, ctx core.GlobalContext) {
fmt.Println("HomePage mounted")
}).
Build()
// Automatically registered in app as "HomePage"
// Query the registry
app.Has("HomePage") // true
app.Get("HomePage") // *ModuleDefinition
app.GetNames() // ["HomePage"]
app.Size() // 1
// Manual registration
app.Register("Settings", settingsModule)
app.Unregister("Settings")
app.Clear()Routing
The HypenRouter provides URL-based navigation with pattern matching.
Basic Navigation
router := core.NewHypenRouter()
// Navigate
router.Push("/users/123")
router.Replace("/login")
router.Back()
router.Forward()Querying Routes
// Current path
router.GetCurrentPath() // "/users/123"
// Route parameters (from pattern matching)
router.GetParams() // map[string]string{"id": "123"}
// Query parameters
router.GetQuery() // map[string]string{"tab": "posts"}
// Full state snapshot
state := router.GetState()
// state.CurrentPath, state.Params, state.Query, state.PreviousPathPattern Matching
// Exact match
match := router.MatchPath("/users/:id", "/users/42")
// match.Params = {"id": "42"}
// Wildcard
match = router.MatchPath("/dashboard/*", "/dashboard/settings/profile")
// match.Path = "/dashboard/settings/profile"
// Check if pattern matches current path
router.IsActive("/users/:id") // true
// Build URL with query parameters
url := router.BuildURL("/search", map[string]string{"q": "hello", "page": "2"})
// "/search?page=2&q=hello"Listening for Changes
unsubscribe := router.OnNavigate(func(route core.RouteState) {
fmt.Printf("Navigated to: %s (from: %s)\n", route.CurrentPath, route.PreviousPath)
})
// Stop listening
unsubscribe()Managed Router
Automatically mount and unmount modules based on the current route:
managed := core.NewManagedRouter(router, engine, app, globalCtx).
AddRoute(core.RouteDefinition{
Path: "/",
Component: "HomePage",
}).
AddRoute(core.RouteDefinition{
Path: "/profile/:id",
Component: "ProfilePage",
}).
AddRoute(core.RouteDefinition{
Path: "/settings",
Module: settingsModule, // Direct module reference instead of registry
})
managed.Start()
// Navigate — ProfilePage module mounts automatically
router.Push("/profile/123")
active := managed.GetActiveModule()
activeRoute := managed.GetActiveRoute()
managed.Stop()Event System
Pub/sub messaging for decoupled communication:
emitter := core.CreateEventEmitter()
// Subscribe
unsubscribe := emitter.On("userLoggedIn", func(payload any) {
user := payload.(map[string]any)
fmt.Printf("User logged in: %s\n", user["name"])
})
// Emit
emitter.Emit("userLoggedIn", map[string]any{"name": "Alice"})
// Unsubscribe
unsubscribe()
// One-time listener (auto-unsubscribes after first call)
emitter.Once("ready", func(payload any) {
fmt.Println("Ready!")
})
// Query
emitter.ListenerCount("userLoggedIn") // int
emitter.EventNames() // []string
// Cleanup
emitter.RemoveAllListeners("userLoggedIn")
emitter.ClearAll()Built-in Framework Events
const (
EventModuleCreated = "module:created"
EventModuleDestroyed = "module:destroyed"
EventRouteChanged = "route:changed"
EventStateUpdated = "state:updated"
EventActionDispatched = "action:dispatched"
EventError = "error"
)Global Context
Cross-module communication using HypenGlobalContext:
globalCtx := core.NewHypenGlobalContext()
// Register modules
globalCtx.RegisterModule("counter", counterInstance)
globalCtx.RegisterModule("auth", authInstance)
// Access other modules
counterRef := globalCtx.GetModule("counter")
counterRef.State() // *ObservableState
counterRef.GetState() // map[string]any (snapshot)
counterRef.SetState(map[string]any{"count": 42}) // update state
// Query
globalCtx.HasModule("counter") // true
globalCtx.GetModuleIds() // ["counter", "auth"]
globalCtx.GetGlobalState() // combined state from all modules
// Router
globalCtx.SetRouter(router)
globalCtx.GetRouter()
// Events (via global context)
globalCtx.Emit("countChanged", 42)
unsub := globalCtx.On("countChanged", func(payload any) {
fmt.Printf("Count: %v\n", payload)
})
unsub()
// Typed event emitter
globalCtx.Events() // *TypedEventEmitter
// Debug info
info := globalCtx.Debug()
// { ModuleCount, ModuleIDs, HasRouter, EventCount }Component Loading
Discover and load .hypen components from the filesystem:
loader := core.NewComponentLoader()
// Register manually
loader.Register("Counter", counterModule, counterTemplate, "./components/Counter")
// Register with just module + template
loader.RegisterWithModule("Counter", counterModule, template)
// Load from a single component directory
err := loader.LoadFromDirectory("Counter", "./components/Counter")
// Expects component.hypen in the directory
// Auto-discover and load all components from a base directory
err = loader.LoadFromComponentsDir("./components")
// Scans for subdirectories containing component.hypen files
// Query
loader.Has("Counter") // true
loader.Get("Counter") // *ComponentDefinition
loader.GetNames() // []string
loader.GetAll() // []*ComponentDefinition
// Global instance
core.ComponentLoaderInstance.LoadFromComponentsDir("./components")Component Resolver
Resolve imports from Hypen DSL templates:
resolver := core.NewComponentResolver(&core.ResolverOptions{
BaseDir: "./components",
Cache: true,
App: app, // HypenApp registry for lookup
})
// Parse imports from DSL
imports := core.ParseImports(`
import { Header, Footer } from "./shared"
import MainContent from "./pages/main"
`)
// Resolve imports
for _, stmt := range imports {
resolved, err := resolver.Resolve(stmt)
// resolved: map[string]*ResolvedComponent
}
// Remove imports from DSL (for engine rendering)
cleanDSL := core.RemoveImports(dslWithImports)
// Cache management
resolver.ClearCache()
resolver.GetCacheSize()RemoteServer API
RemoteServer streams UI over WebSocket to any Hypen client using a fluent builder pattern:
server := remote.NewRemoteServer().
Module("MyApp", moduleConfig).
UI(uiTemplate).
Config(remote.ServerConfig{Port: 3000, Hostname: "0.0.0.0"}).
OnConnection(connectHandler).
OnDisconnection(disconnectHandler).
Listen(3000)Builder Methods
| Method | Description |
|---|---|
.Module(name, config) | Set the module to serve |
.WithState(name, state) | Quick setup with just name and state |
.OnAction(handler) | Set action handler (simple mode) |
.UI(dsl) | Set the Hypen DSL template |
.Config(config) | Server configuration (port, hostname) |
.OnConnection(callback) | Called when a client connects |
.OnDisconnection(callback) | Called when a client disconnects |
.Listen(port?) | Start the WebSocket server |
.Stop() | Stop the server |
Server Methods
server.GetURL() // WebSocket URL string
server.GetClientCount() // number of active connections
// Send to specific client
server.SendPatches(conn, patches)
server.UpdateClientState(conn, state)
// Broadcast to all clients
server.Broadcast(msg)
server.BroadcastPatches(patches)
server.BroadcastState(state)Configuration
remote.ServerConfig{
Port: 3000, // default: 3000
Hostname: "0.0.0.0", // default: "0.0.0.0"
}RemoteEngine (Client)
Connect to a Hypen server from Go:
client := remote.NewRemoteEngine("ws://localhost:3000/ws", &remote.EngineOptions{
AutoReconnect: true,
ReconnectInterval: 3 * time.Second,
MaxReconnectAttempts: 10,
}).
OnPatches(func(patches []remote.Patch) {
for _, p := range patches {
fmt.Printf("Patch: %s %s\n", p.Type, p.ID)
}
}).
OnStateUpdate(func(state any) {
fmt.Printf("State: %v\n", state)
}).
OnConnect(func() {
fmt.Println("Connected!")
}).
OnDisconnect(func() {
fmt.Println("Disconnected")
}).
OnError(func(err error) {
fmt.Printf("Error: %v\n", err)
})
err := client.Connect()
defer client.Disconnect()
// Dispatch actions to the server
client.DispatchAction("increment", map[string]any{"step": 5})
// Query
client.GetConnectionState() // "connected" | "connecting" | "disconnected" | "error"
client.GetCurrentState() // current state from server
client.GetRevision() // current revision numberWebSocket Protocol
Client → Server Messages
| Type | Fields | Description |
|---|---|---|
dispatchAction | module, action, payload? | Dispatch action to module |
Server → Client Messages
| Type | Fields | Description |
|---|---|---|
initialTree | module, state, patches, revision | Full UI tree on connect |
patch | module, patches, revision | Incremental UI updates |
stateUpdate | module, state, revision | Full state snapshot |
WASM Engine
Use the WASM engine for server-side rendering with the full Hypen parser and reconciler:
engine, err := core.NewWasmEngine(core.WasmEngineConfig{
WasmPath: "./hypen_engine.wasm",
Primitives: []string{"Text", "Column", "Row", "Button", "Input", "Image"},
})
if err != nil {
log.Fatal(err)
}
defer engine.Close()
// Register a component
engine.RegisterComponent("Header", headerDSL, "/components/Header")
// Set up module
engine.SetModule("Counter", []string{"increment"}, []string{"count"}, map[string]any{"count": 0})
// Register action handler
engine.OnAction("increment", func(action core.Action) {
// Handle action
})
// Set render callback
engine.SetPatchCallback(func(patches []core.Patch) {
// Send patches to clients
})
// Render
patches, err := engine.RenderSource(`
Column {
Text("Hello, world!")
}
`)
// Update state and get new patches
patches, err = engine.UpdateState(map[string]any{"count": 1})
// Document rendering with import resolution
patches, err = engine.RenderDocument(source, resolver)
// Debug
jsonAST, err := engine.ParseToJSON(source)
rev := engine.GetRevision()
engine.ClearTree()WASM Configuration
type WasmEngineConfig struct {
WasmPath string // Path to hypen_engine.wasm file
WasmBytes []byte // Alternative: raw WASM bytes (skips file read)
Primitives []string // Element types to register
}Error Types
The SDK provides typed error classes:
// Check error type
if core.IsEngineError(err, core.ErrParse) {
fmt.Println("DSL parsing failed:", err)
}
// Error codes
core.ErrParse // DSL parsing failed
core.ErrComponentNotFound // Component not in registry
core.ErrRender // Rendering/reconciliation failed
core.ErrActionNotFound // No handler for action
core.ErrState // State operation failed
core.ErrExpression // Expression evaluation failed
core.ErrNotInitialized // Engine not initializedTesting
MockEngine
Test modules without the WASM engine:
func TestCounter(t *testing.T) {
engine := core.NewMockEngine()
counter := core.NewAppBuilder(map[string]any{"count": 0}, nil).
OnAction("increment", func(ctx core.ActionHandlerContext) {
count := ctx.State.Get("count").(int)
ctx.State.Set("count", count+1)
}).
Build()
instance := core.NewModuleInstance(engine, counter, nil, nil)
// Dispatch action
engine.TriggerAction("increment", nil)
// Verify state
count := instance.GetState()["count"].(int)
if count != 1 {
t.Errorf("Expected count=1, got %d", count)
}
// Inspect recorded changes
changes := engine.GetStateChanges()
actions := engine.GetDispatchedActions()
// Check registrations
engine.HasAction("increment") // true
engine.GetRegisteredActions() // ["increment"]
// Reset for next test
engine.Reset()
}TestRenderer
Capture render events for assertions:
func TestRendering(t *testing.T) {
renderer := core.NewTestRenderer()
patches := []core.Patch{
{Type: core.PatchCreate, ID: "1", ElementType: "Column"},
{Type: core.PatchCreate, ID: "2", ElementType: "Text"},
{Type: core.PatchSetText, ID: "2", Text: "Hello"},
{Type: core.PatchInsert, ParentID: "1", ID: "2"},
}
renderer.ApplyPatches(patches)
// Inspect events
events := renderer.Events
if len(events) != 4 {
t.Errorf("Expected 4 events, got %d", len(events))
}
// Check event types
types := renderer.EventTypes()
// ["create", "create", "setText", "insert"]
// Clear for next test
renderer.ClearEvents()
}ConsoleRenderer
Print patches to stdout for debugging:
renderer := core.NewConsoleRenderer()
renderer.ApplyPatches(patches)
// Prints each patch to consoleLogging
Configure logging for debugging:
// Enable debug mode (verbose output)
core.SetDebugMode(true)
// Set global log level
core.SetLogLevel(core.LogLevelDebug) // Debug, Info, Warn, Error, None
// Custom output
core.SetLogOutput(logFile)
// Check mode
core.IsDebugMode()
core.GetLogLevel()Framework subsystems have individual loggers:
// Available loggers
core.LogEngine // Engine operations
core.LogRouter // Navigation events
core.LogState // State mutations
core.LogEvents // Event emissions
core.LogRemote // Remote server/client
core.LogRenderer // Patch application
core.LogModule // Module lifecycle
core.LogLifecycle // Lifecycle hooks
core.LogLoader // Component loading
core.LogContext // Global context
core.LogDiscovery // Component discovery
// Per-logger level
core.LogState.SetLevel(core.LogLevelWarn)
// Child loggers
childLog := core.LogEngine.Child("WASM")
childLog.Debug("Initializing WASM runtime")Deployment
Docker
FROM golang:1.21-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server .
FROM alpine:latest
WORKDIR /app
COPY --from=build /app/server .
COPY --from=build /app/hypen_engine.wasm .
COPY --from=build /app/components ./components
EXPOSE 3000
CMD ["./server"]Production Checklist
- Include the
hypen_engine.wasmfile in your deployment - Each client gets its own module instance — plan memory accordingly
- Use
defer server.Stop()for graceful shutdown - Monitor active connections via
server.GetClientCount() - Set appropriate
MaxConnectionsin server options for capacity planning - Add authentication at the HTTP layer before WebSocket upgrade
Requirements
- Go 1.21+
gorilla/websocket(WebSocket support)tetratelabs/wazero(WASM runtime, no CGo required)
See Also
- TypeScript SDK — TypeScript server SDK with sessions and component discovery
- Remote App Tutorial — Step-by-step guide to building a remote app
- Error Handling — Error recovery patterns across SDKs
- Web Adapter — Client-side rendering for browsers