diff --git a/tsunami/app/atom.go b/tsunami/app/atom.go index 2800f7f65f..587e04118c 100644 --- a/tsunami/app/atom.go +++ b/tsunami/app/atom.go @@ -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 diff --git a/tsunami/app/defaultclient.go b/tsunami/app/defaultclient.go index dde0d6f264..17dbfbe84f 100644 --- a/tsunami/app/defaultclient.go +++ b/tsunami/app/defaultclient.go @@ -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. diff --git a/tsunami/demo/cpuchart/app.go b/tsunami/demo/cpuchart/app.go index 3d6802e3ef..215cfb5d15 100644 --- a/tsunami/demo/cpuchart/app.go +++ b/tsunami/demo/cpuchart/app.go @@ -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", diff --git a/tsunami/demo/githubaction/app.go b/tsunami/demo/githubaction/app.go index 2a8ef1a7e2..0b54d43a0f 100644 --- a/tsunami/demo/githubaction/app.go +++ b/tsunami/demo/githubaction/app.go @@ -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", diff --git a/tsunami/demo/pomodoro/app.go b/tsunami/demo/pomodoro/app.go index eda19c0835..12926edd07 100644 --- a/tsunami/demo/pomodoro/app.go +++ b/tsunami/demo/pomodoro/app.go @@ -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(), diff --git a/tsunami/demo/recharts/app.go b/tsunami/demo/recharts/app.go index b558292d1a..12c2ca2ec9 100644 --- a/tsunami/demo/recharts/app.go +++ b/tsunami/demo/recharts/app.go @@ -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 { diff --git a/tsunami/demo/tabletest/app.go b/tsunami/demo/tabletest/app.go index cb086f5709..61aae27215 100644 --- a/tsunami/demo/tabletest/app.go +++ b/tsunami/demo/tabletest/app.go @@ -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 diff --git a/tsunami/demo/tsunamiconfig/app.go b/tsunami/demo/tsunamiconfig/app.go index 77730763ac..76d0591bfd 100644 --- a/tsunami/demo/tsunamiconfig/app.go +++ b/tsunami/demo/tsunamiconfig/app.go @@ -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 { diff --git a/tsunami/engine/atomimpl.go b/tsunami/engine/atomimpl.go index 3d9c5a4896..092d1dd463 100644 --- a/tsunami/engine/atomimpl.go +++ b/tsunami/engine/atomimpl.go @@ -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() +} diff --git a/tsunami/engine/hooks.go b/tsunami/engine/hooks.go index d4db65d8cc..179871e903 100644 --- a/tsunami/engine/hooks.go +++ b/tsunami/engine/hooks.go @@ -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() { diff --git a/tsunami/engine/rootelem.go b/tsunami/engine/rootelem.go index 15ed18442f..bdd54bc10a 100644 --- a/tsunami/engine/rootelem.go +++ b/tsunami/engine/rootelem.go @@ -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 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 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 } diff --git a/tsunami/engine/schema.go b/tsunami/engine/schema.go new file mode 100644 index 0000000000..fe4cf50afa --- /dev/null +++ b/tsunami/engine/schema.go @@ -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") +} diff --git a/tsunami/engine/serverhandlers.go b/tsunami/engine/serverhandlers.go index 05b8a38364..80dcb0292f 100644 --- a/tsunami/engine/serverhandlers.go +++ b/tsunami/engine/serverhandlers.go @@ -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 diff --git a/tsunami/prompts/system.md b/tsunami/prompts/system.md index e07212ea77..92b79f1c3d 100644 --- a/tsunami/prompts/system.md +++ b/tsunami/prompts/system.md @@ -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", + }) ) ``` -#### Using Global Atoms - -Global atoms work exactly like local atoms - same Get/Set interface: +- `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. -```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 - } +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). - handleRetry := func() { - maxRetries.SetFn(func(current int) int { - return current + 1 // Functional update - }) - } +#### Using Global Atoms - 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{ diff --git a/tsunami/util/util.go b/tsunami/util/util.go index e8d879c783..bb3cf39776 100644 --- a/tsunami/util/util.go +++ b/tsunami/util/util.go @@ -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 +}