HypenHypen
Server SDKs

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-golang

Quick 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) *AppBuilder
  • initialState — initial state map (required)
  • options — module configuration (can be nil)
  • app — optional HypenApp registry 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:

FieldTypeDescription
ErrorerrorThe error or recovered panic
State*ObservableStateCurrent state (for inspection)
ActionNamestringSet if error came from an action handler
Lifecyclestring"created" or "destroyed" if from a lifecycle hook

Return values:

ReturnEffect
nilDefault: 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 *ModuleDefinition

State 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 notification

Change 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.PreviousPath

Pattern 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

MethodDescription
.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 number

WebSocket Protocol

Client → Server Messages

TypeFieldsDescription
dispatchActionmodule, action, payload?Dispatch action to module

Server → Client Messages

TypeFieldsDescription
initialTreemodule, state, patches, revisionFull UI tree on connect
patchmodule, patches, revisionIncremental UI updates
stateUpdatemodule, state, revisionFull 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 initialized

Testing

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 console

Logging

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.wasm file 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 MaxConnections in 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