implement /api/schemas (json schemas from tsunami atoms /api/config /api/data) (#2335)

This commit is contained in:
Mike Sawka 2025-09-12 00:09:41 -07:00 committed by GitHub
parent 2783eeb60e
commit 8c47b825ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 663 additions and 117 deletions

View file

@ -12,6 +12,22 @@ import (
"github.com/wavetermdev/waveterm/tsunami/util"
)
// AtomMeta provides metadata about an atom for validation and documentation
type AtomMeta struct {
Desc string // short, user-facing
Units string // "ms", "GiB", etc.
Min *float64 // optional minimum (numeric types)
Max *float64 // optional maximum (numeric types)
Enum []string // allowed values if finite set
Pattern string // regex constraint for strings
}
// Atom[T] represents a typed atom implementation
type Atom[T any] struct {
name string
client *engine.ClientImpl
}
// logInvalidAtomSet logs an error when an atom is being set during component render
func logInvalidAtomSet(atomName string) {
_, file, line, ok := runtime.Caller(2)
@ -58,12 +74,6 @@ func logMutationWarning(atomName string) {
}
}
// Atom[T] represents a typed atom implementation
type Atom[T any] struct {
name string
client *engine.ClientImpl
}
// AtomName implements the vdom.Atom interface
func (a Atom[T]) AtomName() string {
return a.name

View file

@ -16,6 +16,10 @@ func DefineComponent[P any](name string, renderFn func(props P) any) vdom.Compon
return engine.DefineComponentEx(engine.GetDefaultClient(), name, renderFn)
}
func Ptr[T any](v T) *T {
return &v
}
func SetGlobalEventHandler(handler func(event vdom.VDomEvent)) {
engine.GetDefaultClient().SetGlobalEventHandler(handler)
}
@ -35,18 +39,20 @@ func SendAsyncInitiation() error {
return engine.GetDefaultClient().SendAsyncInitiation()
}
func ConfigAtom[T any](name string, defaultValue T) Atom[T] {
func ConfigAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] {
fullName := "$config." + name
client := engine.GetDefaultClient()
atom := engine.MakeAtomImpl(defaultValue)
engineMeta := convertAppMetaToEngineMeta(meta)
atom := engine.MakeAtomImpl(defaultValue, engineMeta)
client.Root.RegisterAtom(fullName, atom)
return Atom[T]{name: fullName, client: client}
}
func DataAtom[T any](name string, defaultValue T) Atom[T] {
func DataAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] {
fullName := "$data." + name
client := engine.GetDefaultClient()
atom := engine.MakeAtomImpl(defaultValue)
engineMeta := convertAppMetaToEngineMeta(meta)
atom := engine.MakeAtomImpl(defaultValue, engineMeta)
client.Root.RegisterAtom(fullName, atom)
return Atom[T]{name: fullName, client: client}
}
@ -54,11 +60,25 @@ func DataAtom[T any](name string, defaultValue T) Atom[T] {
func SharedAtom[T any](name string, defaultValue T) Atom[T] {
fullName := "$shared." + name
client := engine.GetDefaultClient()
atom := engine.MakeAtomImpl(defaultValue)
atom := engine.MakeAtomImpl(defaultValue, nil)
client.Root.RegisterAtom(fullName, atom)
return Atom[T]{name: fullName, client: client}
}
func convertAppMetaToEngineMeta(appMeta *AtomMeta) *engine.AtomMeta {
if appMeta == nil {
return nil
}
return &engine.AtomMeta{
Description: appMeta.Desc,
Units: appMeta.Units,
Min: appMeta.Min,
Max: appMeta.Max,
Enum: appMeta.Enum,
Pattern: appMeta.Pattern,
}
}
// HandleDynFunc registers a dynamic HTTP handler function with the internal http.ServeMux.
// The pattern MUST start with "/dyn/" to be valid. This allows registration of dynamic
// routes that can be handled at runtime.

View file

@ -11,8 +11,12 @@ import (
// Global atoms for config and data
var (
dataPointCountAtom = app.ConfigAtom("dataPointCount", 60)
cpuDataAtom = app.DataAtom("cpuData", func() []CPUDataPoint {
dataPointCountAtom = app.ConfigAtom("dataPointCount", 60, &app.AtomMeta{
Desc: "Number of CPU data points to display in the chart",
Min: app.Ptr(10.0),
Max: app.Ptr(300.0),
})
cpuDataAtom = app.DataAtom("cpuData", func() []CPUDataPoint {
// Initialize with empty data points to maintain consistent chart size
dataPointCount := 60 // Default value for initialization
initialData := make([]CPUDataPoint, dataPointCount)
@ -24,13 +28,15 @@ var (
}
}
return initialData
}())
}(), &app.AtomMeta{
Desc: "Historical CPU usage data points for charting",
})
)
type CPUDataPoint struct {
Time int64 `json:"time"` // Unix timestamp in seconds
CPUUsage *float64 `json:"cpuUsage"` // CPU usage percentage (nil for empty slots)
Timestamp string `json:"timestamp"` // Human readable timestamp
Time int64 `json:"time" desc:"Unix timestamp (seconds since epoch)" units:"s"`
CPUUsage *float64 `json:"cpuUsage" desc:"CPU usage percentage" units:"%" min:"0" max:"100"`
Timestamp string `json:"timestamp" desc:"Human-readable HH:MM:SS"`
}
type StatsPanelProps struct {
@ -207,7 +213,7 @@ var App = app.DefineComponent("App", func(_ struct{}) any {
}, "Real-Time CPU Usage Monitor"),
vdom.H("p", map[string]any{
"className": "text-gray-400",
}, "Live CPU usage data collected using gopsutil, displaying 60 seconds of history"),
}, "Live CPU usage data collected using gopsutil, displaying ", dataPointCount, " seconds of history"),
),
// Controls
@ -334,7 +340,7 @@ var App = app.DefineComponent("App", func(_ struct{}) any {
vdom.H("span", map[string]any{
"className": "text-blue-400 mt-1",
}, "•"),
"Rolling window of 60 seconds of historical data",
"Rolling window of ", dataPointCount, " seconds of historical data",
),
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",

View file

@ -17,14 +17,37 @@ import (
// Global atoms for config and data
var (
pollIntervalAtom = app.ConfigAtom("pollInterval", 5)
repositoryAtom = app.ConfigAtom("repository", "wavetermdev/waveterm")
workflowAtom = app.ConfigAtom("workflow", "build-helper.yml")
maxWorkflowRunsAtom = app.ConfigAtom("maxWorkflowRuns", 10)
workflowRunsAtom = app.DataAtom("workflowRuns", []WorkflowRun{})
lastErrorAtom = app.DataAtom("lastError", "")
isLoadingAtom = app.DataAtom("isLoading", true)
lastRefreshTimeAtom = app.DataAtom("lastRefreshTime", time.Time{})
pollIntervalAtom = app.ConfigAtom("pollInterval", 5, &app.AtomMeta{
Desc: "Polling interval for GitHub API requests",
Units: "s",
Min: app.Ptr(1.0),
Max: app.Ptr(300.0),
})
repositoryAtom = app.ConfigAtom("repository", "wavetermdev/waveterm", &app.AtomMeta{
Desc: "GitHub repository in owner/repo format",
Pattern: `^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$`,
})
workflowAtom = app.ConfigAtom("workflow", "build-helper.yml", &app.AtomMeta{
Desc: "GitHub Actions workflow file name",
Pattern: `^.+\.(yml|yaml)$`,
})
maxWorkflowRunsAtom = app.ConfigAtom("maxWorkflowRuns", 10, &app.AtomMeta{
Desc: "Maximum number of workflow runs to fetch",
Min: app.Ptr(1.0),
Max: app.Ptr(100.0),
})
workflowRunsAtom = app.DataAtom("workflowRuns", []WorkflowRun{}, &app.AtomMeta{
Desc: "List of GitHub Actions workflow runs",
})
lastErrorAtom = app.DataAtom("lastError", "", &app.AtomMeta{
Desc: "Last error message from GitHub API",
})
isLoadingAtom = app.DataAtom("isLoading", true, &app.AtomMeta{
Desc: "Loading state for workflow data fetch",
})
lastRefreshTimeAtom = app.DataAtom("lastRefreshTime", time.Time{}, &app.AtomMeta{
Desc: "Timestamp of last successful data refresh",
})
)
type WorkflowRun struct {
@ -388,7 +411,7 @@ var App = app.DefineComponent("App",
vdom.H("span", map[string]any{
"className": "text-blue-400 mt-1",
}, "•"),
"Polls GitHub API every 5 seconds for real-time updates",
"Polls GitHub API every ", pollInterval, " seconds for real-time updates",
),
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",

View file

@ -18,7 +18,12 @@ var (
BreakMode = Mode{Name: "Break", Duration: 5}
// Data atom to expose remaining seconds to external systems
remainingSecondsAtom = app.DataAtom("remainingSeconds", WorkMode.Duration*60)
remainingSecondsAtom = app.DataAtom("remainingSeconds", WorkMode.Duration*60, &app.AtomMeta{
Desc: "Remaining seconds in current pomodoro timer",
Units: "s",
Min: app.Ptr(0.0),
Max: app.Ptr(3600.0),
})
)
type TimerDisplayProps struct {
@ -34,7 +39,6 @@ type ControlButtonsProps struct {
OnMode func(int) `json:"onMode"`
}
var TimerDisplay = app.DefineComponent("TimerDisplay",
func(props TimerDisplayProps) any {
minutes := props.RemainingSeconds / 60
@ -151,7 +155,7 @@ var App = app.DefineComponent("App",
if !isRunning.Get() {
return
}
// Calculate remaining time and update remainingSeconds
elapsed := time.Since(startTime.Current)
remaining := totalDuration.Current - elapsed
@ -190,7 +194,7 @@ var App = app.DefineComponent("App",
),
TimerDisplay(TimerDisplayProps{
RemainingSeconds: remainingSecondsAtom.Get(),
Mode: mode.Get(),
Mode: mode.Get(),
}),
ControlButtons(ControlButtonsProps{
IsRunning: isRunning.Get(),

View file

@ -10,9 +10,16 @@ import (
// Global atoms for config and data
var (
chartDataAtom = app.DataAtom("chartData", generateInitialData())
chartTypeAtom = app.ConfigAtom("chartType", "line")
isAnimatingAtom = app.SharedAtom("isAnimating", false)
chartDataAtom = app.DataAtom("chartData", generateInitialData(), &app.AtomMeta{
Desc: "Chart data points for system metrics visualization",
})
chartTypeAtom = app.ConfigAtom("chartType", "line", &app.AtomMeta{
Desc: "Type of chart to display",
Enum: []string{"line", "area", "bar"},
})
isAnimatingAtom = app.ConfigAtom("isAnimating", false, &app.AtomMeta{
Desc: "Whether the chart is currently animating with live data",
})
)
type DataPoint struct {

View file

@ -31,6 +31,8 @@ var sampleData = app.DataAtom("sampleData", []Person{
{Name: "Henry Taylor", Age: 33, Email: "henry@example.com", City: "San Diego"},
{Name: "Ivy Chen", Age: 26, Email: "ivy@example.com", City: "Dallas"},
{Name: "Jack Anderson", Age: 31, Email: "jack@example.com", City: "San Jose"},
}, &app.AtomMeta{
Desc: "Sample person data for table display testing",
})
// The App component is the required entry point for every Tsunami application

View file

@ -16,7 +16,10 @@ import (
// Global atoms for config
var (
serverURLAtom = app.ConfigAtom("serverURL", "")
serverURLAtom = app.ConfigAtom("serverURL", "", &app.AtomMeta{
Desc: "Server URL for config API (can be full URL, hostname:port, or just port)",
Pattern: `^(https?://.*|[a-zA-Z0-9.-]+:\d+|\d+|[a-zA-Z0-9.-]+)$`,
})
)
type URLInputProps struct {

View file

@ -6,20 +6,33 @@ package engine
import (
"encoding/json"
"fmt"
"reflect"
"sync"
)
// AtomMeta provides metadata about an atom for validation and documentation
type AtomMeta struct {
Description string // short, user-facing
Units string // "ms", "GiB", etc.
Min *float64 // optional minimum (numeric types)
Max *float64 // optional maximum (numeric types)
Enum []string // allowed values if finite set
Pattern string // regex constraint for strings
}
type AtomImpl[T any] struct {
lock *sync.Mutex
val T
usedBy map[string]bool // component waveid -> true
meta *AtomMeta // optional metadata
}
func MakeAtomImpl[T any](initialVal T) *AtomImpl[T] {
func MakeAtomImpl[T any](initialVal T, meta *AtomMeta) *AtomImpl[T] {
return &AtomImpl[T]{
lock: &sync.Mutex{},
val: initialVal,
usedBy: make(map[string]bool),
meta: meta,
}
}
@ -84,3 +97,13 @@ func (a *AtomImpl[T]) GetUsedBy() []string {
}
return keys
}
func (a *AtomImpl[T]) GetMeta() *AtomMeta {
a.lock.Lock()
defer a.lock.Unlock()
return a.meta
}
func (a *AtomImpl[T]) GetAtomType() reflect.Type {
return reflect.TypeOf((*T)(nil)).Elem()
}

View file

@ -77,7 +77,7 @@ func UseLocal(vc *RenderContextImpl, initialVal any) string {
atomName := "$local." + vc.GetCompWaveId() + "#" + strconv.Itoa(hookVal.Idx)
if !hookVal.Init {
hookVal.Init = true
atom := MakeAtomImpl(initialVal)
atom := MakeAtomImpl(initialVal, nil)
vc.Root.RegisterAtom(atomName, atom)
closedAtomName := atomName
hookVal.UnmountFn = func() {

View file

@ -29,6 +29,8 @@ type genAtom interface {
SetVal(any) error
SetUsedBy(string, bool)
GetUsedBy() []string
GetMeta() *AtomMeta
GetAtomType() reflect.Type
}
type RootElem struct {
@ -82,30 +84,35 @@ func (r *RootElem) addEffectWork(id string, effectIndex int, compTag string) {
r.EffectWorkQueue = append(r.EffectWorkQueue, &EffectWorkElem{WaveId: id, EffectIndex: effectIndex, CompTag: compTag})
}
func (r *RootElem) GetDataMap() map[string]any {
// getAtomsByPrefix extracts all atoms that match the given prefix from RootElem
func (r *RootElem) getAtomsByPrefix(prefix string) map[string]genAtom {
r.atomLock.Lock()
defer r.atomLock.Unlock()
result := make(map[string]any)
result := make(map[string]genAtom)
for atomName, atom := range r.Atoms {
if strings.HasPrefix(atomName, "$data.") {
strippedName := strings.TrimPrefix(atomName, "$data.")
result[strippedName] = atom.GetVal()
if strings.HasPrefix(atomName, prefix) {
strippedName := strings.TrimPrefix(atomName, prefix)
result[strippedName] = atom
}
}
return result
}
func (r *RootElem) GetConfigMap() map[string]any {
r.atomLock.Lock()
defer r.atomLock.Unlock()
func (r *RootElem) GetDataMap() map[string]any {
atoms := r.getAtomsByPrefix("$data.")
result := make(map[string]any)
for atomName, atom := range r.Atoms {
if strings.HasPrefix(atomName, "$config.") {
strippedName := strings.TrimPrefix(atomName, "$config.")
result[strippedName] = atom.GetVal()
}
for name, atom := range atoms {
result[name] = atom.GetVal()
}
return result
}
func (r *RootElem) GetConfigMap() map[string]any {
atoms := r.getAtomsByPrefix("$config.")
result := make(map[string]any)
for name, atom := range atoms {
result[name] = atom.GetVal()
}
return result
}

302
tsunami/engine/schema.go Normal file
View file

@ -0,0 +1,302 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package engine
import (
"fmt"
"reflect"
"strconv"
"strings"
"time"
"github.com/wavetermdev/waveterm/tsunami/util"
)
// createStructDefinition creates a JSON schema definition for a struct type
func createStructDefinition(t reflect.Type) map[string]any {
structDef := make(map[string]any)
structDef["type"] = "object"
properties := make(map[string]any)
required := make([]string, 0)
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if !field.IsExported() {
continue
}
// Parse JSON tag
fieldInfo, shouldInclude := util.ParseJSONTag(field)
if !shouldInclude {
continue // Skip this field
}
// If field has "string" option, force schema type to string
var fieldSchema map[string]any
if fieldInfo.AsString {
fieldSchema = map[string]any{"type": "string"}
} else {
fieldSchema = generateShallowJSONSchema(field.Type, nil)
}
// Add description from "desc" tag if present
if desc := field.Tag.Get("desc"); desc != "" {
fieldSchema["description"] = desc
}
// Add enum values from "enum" tag if present (only for string types)
if enumTag := field.Tag.Get("enum"); enumTag != "" && fieldSchema["type"] == "string" {
enumValues := make([]any, 0)
for _, val := range strings.Split(enumTag, ",") {
trimmed := strings.TrimSpace(val)
if trimmed != "" {
enumValues = append(enumValues, trimmed)
}
}
if len(enumValues) > 0 {
fieldSchema["enum"] = enumValues
}
}
// Add units from "units" tag if present
if units := field.Tag.Get("units"); units != "" {
fieldSchema["units"] = units
}
// Add min/max constraints for numeric types
if fieldSchema["type"] == "number" || fieldSchema["type"] == "integer" {
if minTag := field.Tag.Get("min"); minTag != "" {
if minVal, err := strconv.ParseFloat(minTag, 64); err == nil {
fieldSchema["minimum"] = minVal
}
}
if maxTag := field.Tag.Get("max"); maxTag != "" {
if maxVal, err := strconv.ParseFloat(maxTag, 64); err == nil {
fieldSchema["maximum"] = maxVal
}
}
}
// Add pattern constraint for string types
if fieldSchema["type"] == "string" {
if pattern := field.Tag.Get("pattern"); pattern != "" {
fieldSchema["pattern"] = pattern
}
}
properties[fieldInfo.FieldName] = fieldSchema
// Add to required if not a pointer and not marked as omitempty
if field.Type.Kind() != reflect.Ptr && !fieldInfo.OmitEmpty {
required = append(required, fieldInfo.FieldName)
}
}
if len(properties) > 0 {
structDef["properties"] = properties
}
if len(required) > 0 {
structDef["required"] = required
}
return structDef
}
// collectStructDefs walks the type tree and adds struct definitions to defs map
func collectStructDefs(t reflect.Type, defs map[reflect.Type]any) {
switch t.Kind() {
case reflect.Slice, reflect.Array:
if t.Elem() != nil {
collectStructDefs(t.Elem(), defs)
}
case reflect.Map:
if t.Elem() != nil {
collectStructDefs(t.Elem(), defs)
}
case reflect.Struct:
// Skip time.Time since we handle it specially
if t == reflect.TypeOf(time.Time{}) {
return
}
// Skip if we already have this struct definition
if _, exists := defs[t]; exists {
return
}
// Create the struct definition
structDef := createStructDefinition(t)
// Add the definition before recursing into field types
defs[t] = structDef
// Now recurse into field types to collect their struct definitions
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.IsExported() {
_, shouldInclude := util.ParseJSONTag(field)
if shouldInclude {
collectStructDefs(field.Type, defs)
}
}
}
case reflect.Ptr:
collectStructDefs(t.Elem(), defs)
}
}
// annotateSchemaWithAtomMeta applies AtomMeta annotations to a JSON schema
func annotateSchemaWithAtomMeta(schema map[string]any, meta *AtomMeta) {
if meta == nil {
return
}
if meta.Description != "" {
schema["description"] = meta.Description
}
if meta.Units != "" {
schema["units"] = meta.Units
}
// Add numeric constraints for number/integer types
if schema["type"] == "number" || schema["type"] == "integer" {
if meta.Min != nil {
schema["minimum"] = *meta.Min
}
if meta.Max != nil {
schema["maximum"] = *meta.Max
}
}
// Add enum values if specified (only for string types)
if len(meta.Enum) > 0 && schema["type"] == "string" {
enumValues := make([]any, len(meta.Enum))
for i, v := range meta.Enum {
enumValues[i] = v
}
schema["enum"] = enumValues
}
// Add pattern constraint for strings
if schema["type"] == "string" && meta.Pattern != "" {
schema["pattern"] = meta.Pattern
}
}
// generateShallowJSONSchema creates a schema that references definitions instead of recursing
func generateShallowJSONSchema(t reflect.Type, meta *AtomMeta) map[string]any {
schema := make(map[string]any)
defer func() {
annotateSchemaWithAtomMeta(schema, meta)
}()
// Special case for time.Time - treat as string with date-time format
if t == reflect.TypeOf(time.Time{}) {
schema["type"] = "string"
schema["format"] = "date-time"
return schema
}
// Special case for []byte - treat as string with base64 encoding
if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 {
schema["type"] = "string"
schema["contentEncoding"] = "base64"
schema["contentMediaType"] = "application/octet-stream"
return schema
}
switch t.Kind() {
case reflect.String:
schema["type"] = "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
schema["type"] = "integer"
case reflect.Float32, reflect.Float64:
schema["type"] = "number"
case reflect.Bool:
schema["type"] = "boolean"
case reflect.Slice, reflect.Array:
schema["type"] = "array"
if t.Elem() != nil {
schema["items"] = generateShallowJSONSchema(t.Elem(), nil)
}
case reflect.Map:
schema["type"] = "object"
if t.Elem() != nil {
schema["additionalProperties"] = generateShallowJSONSchema(t.Elem(), nil)
}
case reflect.Struct:
// Reference the definition instead of recursing
schema["$ref"] = fmt.Sprintf("#/$defs/%s", t.Name())
case reflect.Ptr:
return generateShallowJSONSchema(t.Elem(), meta)
case reflect.Interface:
schema["type"] = "object"
default:
schema["type"] = "object"
}
return schema
}
// getAtomMeta extracts AtomMeta from the atom
func getAtomMeta(atom genAtom) *AtomMeta {
return atom.GetMeta()
}
// generateSchemaFromAtoms generates a JSON schema from a map of atoms
func generateSchemaFromAtoms(atoms map[string]genAtom, title, description string) map[string]any {
// Collect all struct definitions
defs := make(map[reflect.Type]any)
for _, atom := range atoms {
atomType := atom.GetAtomType()
if atomType != nil {
collectStructDefs(atomType, defs)
}
}
// Generate properties for each atom
properties := make(map[string]any)
for atomName, atom := range atoms {
atomType := atom.GetAtomType()
if atomType != nil {
atomMeta := getAtomMeta(atom)
properties[atomName] = generateShallowJSONSchema(atomType, atomMeta)
}
}
// Build the final schema
schema := map[string]any{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"title": title,
"description": description,
"properties": properties,
"additionalProperties": false,
}
// Add definitions if any
if len(defs) > 0 {
definitions := make(map[string]any)
for t, def := range defs {
definitions[t.Name()] = def
}
schema["$defs"] = definitions
}
return schema
}
// GenerateConfigSchema generates a JSON schema for all config atoms
func GenerateConfigSchema(root *RootElem) map[string]any {
configAtoms := root.getAtomsByPrefix("$config.")
return generateSchemaFromAtoms(configAtoms, "Application Configuration", "Application configuration settings")
}
// GenerateDataSchema generates a JSON schema for all data atoms
func GenerateDataSchema(root *RootElem) map[string]any {
dataAtoms := root.getAtomsByPrefix("$data.")
return generateSchemaFromAtoms(dataAtoms, "Application Data", "Application data schema")
}

View file

@ -43,11 +43,18 @@ func newHTTPHandlers(client *ClientImpl) *httpHandlers {
}
}
func setNoCacheHeaders(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
}
func (h *httpHandlers) registerHandlers(mux *http.ServeMux, opts handlerOpts) {
mux.HandleFunc("/api/render", h.handleRender)
mux.HandleFunc("/api/updates", h.handleSSE)
mux.HandleFunc("/api/data", h.handleData)
mux.HandleFunc("/api/config", h.handleConfig)
mux.HandleFunc("/api/schemas", h.handleSchemas)
mux.HandleFunc("/api/manifest", h.handleManifest(opts.ManifestFile))
mux.HandleFunc("/dyn/", h.handleDynContent)
@ -70,6 +77,8 @@ func (h *httpHandlers) handleRender(w http.ResponseWriter, r *http.Request) {
}
}()
setNoCacheHeaders(w)
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
@ -180,6 +189,8 @@ func (h *httpHandlers) handleData(w http.ResponseWriter, r *http.Request) {
}
}()
setNoCacheHeaders(w)
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
@ -202,6 +213,8 @@ func (h *httpHandlers) handleConfig(w http.ResponseWriter, r *http.Request) {
}
}()
setNoCacheHeaders(w)
switch r.Method {
case http.MethodGet:
h.handleConfigGet(w, r)
@ -261,6 +274,36 @@ func (h *httpHandlers) handleConfigPost(w http.ResponseWriter, r *http.Request)
json.NewEncoder(w).Encode(response)
}
func (h *httpHandlers) handleSchemas(w http.ResponseWriter, r *http.Request) {
defer func() {
panicErr := util.PanicHandler("handleSchemas", recover())
if panicErr != nil {
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
}
}()
setNoCacheHeaders(w)
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
configSchema := GenerateConfigSchema(h.Client.Root)
dataSchema := GenerateDataSchema(h.Client.Root)
result := map[string]any{
"config": configSchema,
"data": dataSchema,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(result); err != nil {
log.Printf("failed to encode schemas response: %v", err)
http.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}
func (h *httpHandlers) handleDynContent(w http.ResponseWriter, r *http.Request) {
defer func() {
panicErr := util.PanicHandler("handleDynContent", recover())
@ -298,12 +341,11 @@ func (h *httpHandlers) handleSSE(w http.ResponseWriter, r *http.Request) {
}
// Set SSE headers
setNoCacheHeaders(w)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Accel-Buffering", "no") // nginx hint
// Use ResponseController for better flushing control
rc := http.NewResponseController(w)
@ -412,6 +454,8 @@ func (h *httpHandlers) handleManifest(manifestFileBytes []byte) http.HandlerFunc
}
}()
setNoCacheHeaders(w)
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return

View file

@ -454,6 +454,21 @@ var MyComponent = app.DefineComponent("MyComponent", func(_ struct{}) any {
For state shared across components or accessible to external systems, declare global atoms as package variables:
#### app.AtomMeta for External Integration
app.ConfigAtom and app.DataAtom require an app.AtomMeta parameter (can pass nil if not needed) to provide schema information for external tools and AI agents. app.SharedAtom does not use app.AtomMeta since it's only for internal state sharing.
```go
type AtomMeta struct {
Desc string // Short, user-facing description
Units string // Units of measurement: "ms", "px", "GiB", etc. Leave blank for counts and unitless values
Min *float64 // Optional minimum value (numeric types only)
Max *float64 // Optional maximum value (numeric types only)
Enum []string // Allowed values if finite set
Pattern string // Regex constraint for strings
}
```
#### Declaring Global Atoms
```go
@ -464,41 +479,37 @@ var (
userPrefs = app.SharedAtom("userPrefs", UserPreferences{})
// ConfigAtom - Configuration that external systems can read/write
theme = app.ConfigAtom("theme", "dark")
apiKey = app.ConfigAtom("apiKey", "")
maxRetries = app.ConfigAtom("maxRetries", 3)
theme = app.ConfigAtom("theme", "dark", &app.AtomMeta{
Desc: "UI theme preference",
Enum: []string{"light", "dark"},
})
apiKey = app.ConfigAtom("apiKey", "", &app.AtomMeta{
Desc: "Authentication key for external services",
Pattern: "^[A-Za-z0-9]{32}$",
})
maxRetries = app.ConfigAtom("maxRetries", 3, &app.AtomMeta{
Desc: "Maximum retry attempts for failed requests",
Min: app.Ptr(0.0),
Max: app.Ptr(10.0),
})
// DataAtom - Application data that external systems can read
currentUser = app.DataAtom("currentUser", UserStats{})
lastPollResult = app.DataAtom("lastPoll", APIResult{})
currentUser = app.DataAtom("currentUser", UserStats{}, &app.AtomMeta{
Desc: "Current user statistics and profile data",
})
lastPollResult = app.DataAtom("lastPoll", APIResult{}, &app.AtomMeta{
Desc: "Result from the most recent API polling operation",
})
)
```
- `app.Ptr(value)` - Helper to create pointers for Min/Max fields. Remember to use float64 literals like `app.Ptr(10.0)` since Min/Max expect \*float64.
app.AtomMeta provides top-level constraints for the atom value. For complex struct types, use struct tags on individual fields (covered in Schema Generation section).
#### Using Global Atoms
Global atoms work exactly like local atoms - same Get/Set interface:
```go
var MyComponent = app.DefineComponent("MyComponent", func(_ struct{}) any {
// Reading atom values (registers render dependency)
loading := isLoading.Get()
currentTheme := theme.Get()
user := currentUser.Get()
// Setting atom values (only in event handlers)
handleToggle := func() {
isLoading.Set(true) // Direct value setting
}
handleRetry := func() {
maxRetries.SetFn(func(current int) int {
return current + 1 // Functional update
})
}
return vdom.H("div", nil, "Loading: ", loading)
})
```
Global atoms work exactly like local atoms - same Get/Set/SetFn interface.
#### Global Atom Types
@ -527,9 +538,37 @@ ConfigAtom and DataAtom automatically create REST endpoints:
- `GET /api/config` - Returns all config atom values
- `POST /api/config` - Updates (merges) config atom values
- `GET /api/data` - Returns all data atom values
- `GET /api/schemas` - Returns JSON schema information for the /api/config and /api/data endpoints based on app.AtomMeta and type reflection information
This makes Tsunami applications naturally suitable for integration with external tools, monitoring systems, and AI agents that need to inspect or configure the application.
#### Schema Generation for External Tools
When using ConfigAtom and DataAtom, you can provide schema metadata to help external AI tools understand your atom structure. Use the optional app.AtomMeta parameter and struct tags for detailed field schemas:
```go
type UserPrefs struct {
Theme string `json:"theme" desc:"UI theme preference" enum:"light,dark"`
FontSize int `json:"fontSize" desc:"Font size in pixels" units:"px" min:"8" max:"32"`
APIEndpoint string `json:"apiEndpoint" desc:"API base URL" pattern:"^https?://.*"`
}
userPrefs := app.ConfigAtom("userPrefs", UserPrefs{}, &app.AtomMeta{
Desc: "User interface and behavior preferences",
})
```
**Supported schema tags:**
- `desc:"..."` - Human-readable description of the field
- `units:"..."` - Units of measurement (ms, px, MB, GB, etc.)
- `min:"123"` - Minimum value for numeric types (parsed as a float)
- `max:"456"` - Maximum value for numeric types (parsed as a float)
- `enum:"val1,val2,val3"` - Comma-separated list of allowed string values
- `pattern:"regex"` - Regular expression for string validation
For complex validation rules or special cases, document them in the app.AtomMeta description (e.g., "Note: 'retryDelays' must contain exactly 3 values in ascending order").
## Component Code Conventions
Tsunami follows specific patterns that make code predictable for both developers and AI code generation. Following these conventions ensures consistent, maintainable code and prevents common bugs.
@ -653,7 +692,7 @@ The style map in props mirrors React's style object pattern, making it familiar
Quick styles can be added using a vdom.H("style", nil, "...") tag. You may also place CSS files in the `static` directory, and serve them directly with:
```go
vdom.H("link", map[string]any{"rel": "stylesheet", "src": "/static/mystyles.css"})
vdom.H("link", map[string]any{"rel": "stylesheet", "href": "/static/mystyles.css"})
```
## Component Definition Pattern
@ -1191,6 +1230,14 @@ type Todo struct {
Completed bool `json:"completed"`
}
// Global state using DataAtom for external integration
var todosAtom = app.DataAtom("todos", []Todo{
{Id: 1, Text: "Learn Tsunami", Completed: false},
{Id: 2, Text: "Build an app", Completed: false},
}, &app.AtomMeta{
Desc: "List of todo items with completion status",
})
type TodoItemProps struct {
Todo Todo `json:"todo"`
OnToggle func() `json:"onToggle"`
@ -1220,59 +1267,55 @@ var TodoItem = app.DefineComponent("TodoItem", func(props TodoItemProps) any {
// Root component must be named "App"
var App = app.DefineComponent("App", func(_ struct{}) any {
// UseLocal returns Atom[T] with Get() and Set() methods
todos := app.UseLocal([]Todo{
{Id: 1, Text: "Learn Tsunami", Completed: false},
{Id: 2, Text: "Build an app", Completed: false},
})
nextId := app.UseLocal(3)
inputText := app.UseLocal("")
// Local state for form and ID management
nextIdAtom := app.UseLocal(3)
inputTextAtom := app.UseLocal("")
// Event handlers
addTodo := func() {
currentInput := inputText.Get()
currentInput := inputTextAtom.Get()
if currentInput == "" {
return
}
currentTodos := todos.Get()
currentNextId := nextId.Get()
currentTodos := todosAtom.Get()
currentNextId := nextIdAtom.Get()
todos.Set(append(currentTodos, Todo{
todosAtom.Set(append(currentTodos, Todo{
Id: currentNextId,
Text: currentInput,
Completed: false,
}))
nextId.Set(currentNextId + 1)
inputText.Set("")
nextIdAtom.Set(currentNextId + 1)
inputTextAtom.Set("")
}
toggleTodo := func(id int) {
todos.SetFn(func(current []Todo) []Todo {
// SetFn automatically deep copies current value
for i := range current {
if current[i].Id == id {
current[i].Completed = !current[i].Completed
break
}
}
return current
})
}
toggleTodo := func(id int) {
todosAtom.SetFn(func(current []Todo) []Todo {
// SetFn automatically deep copies current value
for i := range current {
if current[i].Id == id {
current[i].Completed = !current[i].Completed
break
}
}
return current
})
}
deleteTodo := func(id int) {
currentTodos := todos.Get()
currentTodos := todosAtom.Get()
newTodos := make([]Todo, 0)
for _, todo := range currentTodos {
if todo.Id != id {
newTodos = append(newTodos, todo)
}
}
todos.Set(newTodos)
todosAtom.Set(newTodos)
}
// Read atom values in render code
todoList := todos.Get()
currentInput := inputText.Get()
todoList := todosAtom.Get()
currentInput := inputTextAtom.Get()
return vdom.H("div", map[string]any{
"className": "max-w-[500px] m-5 font-sans",
@ -1290,7 +1333,7 @@ var App = app.DefineComponent("App", func(_ struct{}) any {
"placeholder": "Add new item...",
"value": currentInput,
"onChange": func(e vdom.VDomEvent) {
inputText.Set(e.TargetValue)
inputTextAtom.Set(e.TargetValue)
},
}),
vdom.H("button", map[string]any{

View file

@ -18,9 +18,10 @@ import (
// PanicHandler handles panic recovery and logging.
// It can be called directly with recover() without checking for nil first.
// Example usage:
// defer func() {
// util.PanicHandler("operation name", recover())
// }()
//
// defer func() {
// util.PanicHandler("operation name", recover())
// }()
func PanicHandler(debugStr string, recoverVal any) error {
if recoverVal == nil {
return nil
@ -123,6 +124,7 @@ func GetTypedAtomValue[T any](rawVal any, atomName string) T {
var (
jsonMarshalerT = reflect.TypeOf((*json.Marshaler)(nil)).Elem()
textMarshalerT = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem()
timeType = reflect.TypeOf(time.Time{})
)
func implementsJSON(t reflect.Type) bool {
@ -163,6 +165,11 @@ func validateAtomTypeRecursive(t reflect.Type, seen map[reflect.Type]bool, atomN
return nil
}
// Allow time.Time explicitly
if t == timeType {
return nil
}
switch t.Kind() {
case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
@ -231,3 +238,48 @@ func validateAtomTypeRecursive(t reflect.Type, seen map[reflect.Type]bool, atomN
return makeAtomError(atomName, parentName, fmt.Sprintf("unsupported type %s", t.Kind()))
}
}
type JsonFieldInfo struct {
FieldName string
OmitEmpty bool
AsString bool
Options []string
}
func ParseJSONTag(field reflect.StructField) (JsonFieldInfo, bool) {
tag := field.Tag.Get("json")
// Ignore field
if tag == "-" {
return JsonFieldInfo{}, false
}
name := field.Name
var opts []string
var omitEmpty, asString bool
if tag != "" {
parts := strings.Split(tag, ",")
if parts[0] != "" {
name = parts[0]
}
if len(parts) > 1 {
opts = parts[1:]
for _, opt := range opts {
switch opt {
case "omitempty":
omitEmpty = true
case "string":
asString = true
}
}
}
}
return JsonFieldInfo{
FieldName: name,
OmitEmpty: omitEmpty,
AsString: asString,
Options: opts,
}, true
}