This guide covers everything you need to embed the go-json runtime in your Go application — from basic setup to advanced patterns like custom extensions, I/O sandboxing, and the Bitcode bridge.
Module: github.com/bitcode-framework/go-json
Go version: 1.24+
- Quick Start
- Compilation
- Execution
- Runtime Options
- Resource Limits
- Session Context
- Extensions
- I/O Modules
- Expression Evaluation
- Debugging
- Logging
- Execution Tracing
- Error Handling
- Bitcode Bridge Pattern
package main
import (
"fmt"
"log"
gojson "github.com/bitcode-framework/go-json/runtime"
"github.com/bitcode-framework/go-json/stdlib"
)
func main() {
// 1. Create a registry with the standard library
reg := stdlib.DefaultRegistry()
// 2. Build the runtime
rt := gojson.NewRuntime(
gojson.WithStdlib(reg.All()),
gojson.WithStdlibEnv(reg.EnvVars()),
)
// 3. Compile a program from a file
program, err := rt.CompileFile("program.json")
if err != nil {
log.Fatal(err)
}
// 4. Execute with input
result, err := rt.Execute(program, map[string]any{
"name": "Alice",
"age": 30,
})
if err != nil {
log.Fatal(err)
}
// 5. Read the result
fmt.Println(result.Value) // program return value
fmt.Println(result.Steps) // number of steps executed
fmt.Println(result.Duration) // wall-clock execution time
}go-json separates compilation from execution. You compile a program once, then execute it as many times as you need.
Compiles a .json program from disk. This is the preferred method because it enables import resolution — relative imports are resolved against the file's directory.
program, err := rt.CompileFile("path/to/program.json")Programs compiled with CompileFile are cached by file path.
Compiles a program from raw bytes. Import resolution is not available because there is no base directory to resolve against.
jsonBytes := []byte(`{
"steps": [
{"let": "greeting", "expr": "'Hello, ' + name"},
{"return": "greeting"}
]
}`)
program, err := rt.Compile(jsonBytes)Programs compiled with Compile are cached by content hash.
A CompiledProgram is immutable after compilation. It is safe to execute the same program from multiple goroutines concurrently — each execution gets its own VM instance and variable scope.
program, err := rt.CompileFile("handler.json")
if err != nil {
log.Fatal(err)
}
// Safe to call from multiple goroutines
http.HandleFunc("/process", func(w http.ResponseWriter, r *http.Request) {
result, err := rt.Execute(program, map[string]any{
"method": r.Method,
"path": r.URL.Path,
})
if err != nil {
http.Error(w, err.Error(), 500)
return
}
json.NewEncoder(w).Encode(result.Value)
})Runs a compiled program with the given input map. Input keys become top-level variables in the program.
result, err := rt.Execute(program, map[string]any{
"name": "Alice",
"age": 30,
})The result struct contains:
| Field | Type | Description |
|---|---|---|
Value |
any |
The program's return value |
Steps |
int |
Total steps executed |
Duration |
time.Duration |
Wall-clock execution time |
Trace |
[]TraceEntry |
Execution trace (if tracing is enabled) |
A convenience method that compiles and executes in a single call. The compiled program is cached internally, so repeated calls with the same JSON are efficient.
result, err := rt.ExecuteJSON(programJSON, map[string]any{
"x": 10,
"y": 20,
})Best for one-off or dynamic programs. For programs you execute repeatedly, prefer explicit Compile + Execute.
Calls a specific named function within a compiled program. The input map is passed as the function's arguments.
result, err := rt.ExecuteFunction(program, "calculateDiscount", map[string]any{
"price": 100.0,
"tier": "gold",
})This is used internally by the server handler bridge to invoke route handler functions, but you can use it directly to call any function defined in a go-json program.
All configuration is done through functional options passed to NewRuntime:
rt := gojson.NewRuntime(
gojson.WithStdlib(reg.All()),
gojson.WithLimits(limits),
gojson.WithRuntimeLogger(myLogger),
// ... more options
)| Option | Signature | Description |
|---|---|---|
WithStdlib |
(funcs []expr.Option) |
Register stdlib functions for expressions |
WithStdlibEnv |
(envVars map[string]any) |
Register environment variables for expressions |
WithLimits |
(limits Limits) |
Set resource limits (steps, depth, timeout, etc.) |
WithRuntimeLogger |
(l Logger) |
Set a custom logger |
WithRuntimeDebugger |
(d Debugger) |
Attach a step-level debugger |
WithRuntimeTrace |
(enabled bool) |
Enable execution tracing |
WithSession |
(s *Session) |
Set session context (user, locale, tenant) |
WithRuntimeContext |
(ctx context.Context) |
Set a Go context for cancellation/deadlines |
WithIO |
(modules ...IOModule) |
Enable I/O modules (HTTP, filesystem, etc.) |
WithoutIO |
() |
Explicitly disable all I/O (this is the default) |
WithIOSecurity |
(cfg *SecurityConfig) |
Configure I/O security policies |
WithEnvHandle |
(h *stdlib.EnvHandle) |
Provide env handle from registry (enables WithEnvResolver/WithEnvAccess) |
WithEnvResolver |
(resolver stdlib.EnvResolver) |
Override env() resolver (requires WithEnvHandle) |
WithEnvAccess |
(config *stdlib.EnvAccessConfig) |
Override env() access control (requires WithEnvHandle) |
WithExtension |
(name string, ext Extension) |
Register a host extension |
WithScriptRuntime |
(rt ScriptRuntime) |
Register a script runtime for script: imports |
WithScriptBridge |
(bridge map[string]any) |
Set bridge map passed to all script runtimes |
Script Runtimes:
import (
gojson "github.com/bitcode-framework/go-json/runtime"
"github.com/bitcode-framework/go-json-runtimes/goja"
)
rt := gojson.NewRuntime(
gojson.WithStdlib(reg.All()),
gojson.WithStdlibEnv(reg.EnvVars()),
gojson.WithScriptRuntime(gojaAdapter(goja.New())), // enables script:./file.js
gojson.WithScriptBridge(map[string]any{
"model": myModelFunc,
"db": myDBNamespace,
}),
)Programs can then use script: imports to call external scripts. The ScriptRuntime interface is defined in runtime/script_runtime.go — implement it to add custom script engines.
Customizing env() function:
// Option A: Override at runtime (recommended for dynamic config)
reg := stdlib.DefaultRegistry()
rt := gojson.NewRuntime(
gojson.WithStdlib(reg.All()),
gojson.WithStdlibEnv(reg.EnvVars()),
gojson.WithEnvHandle(reg.EnvHandle()),
gojson.WithEnvResolver(viper.GetString),
gojson.WithEnvAccess(&stdlib.EnvAccessConfig{
Allow: []string{"APP_*", "PUBLIC_*"},
Deny: []string{"*_SECRET", "*_PASSWORD"},
}),
)
// Option B: Configure at registry creation (simpler, static config)
reg := stdlib.DefaultRegistryWithEnv(viper.GetString, &stdlib.EnvAccessConfig{
Deny: []string{"*_SECRET"},
})Resource limits protect your host application from runaway programs.
rt := gojson.NewRuntime(
gojson.WithLimits(gojson.Limits{
MaxSteps: 5000, // max VM steps
MaxDepth: 100, // max call/scope depth
MaxLoopIterations: 1000, // max iterations per loop
MaxVariables: 500, // max variables in scope
MaxVariableSize: 1024 * 1024, // 1 MB per variable
MaxOutputSize: 5 * 1024 * 1024, // 5 MB total output
Timeout: 10 * time.Second, // wall-clock timeout
}),
)| Limit | Default |
|---|---|
MaxSteps |
10,000 |
MaxDepth |
1,000 |
MaxLoopIterations |
10,000 |
MaxVariables |
1,000 |
MaxVariableSize |
10 MB |
MaxOutputSize |
50 MB |
Timeout |
30 seconds |
The runtime enforces hard ceilings that cannot be exceeded regardless of configuration:
| Limit | Hard Maximum |
|---|---|
MaxSteps |
100,000 |
MaxDepth |
10,000 |
MaxLoopIterations |
100,000 |
If you set a value above the hard limit, it is silently clamped.
Session context lets you pass per-request identity and locale information into programs.
rt := gojson.NewRuntime(
gojson.WithSession(&gojson.Session{
UserID: "user-123",
Locale: "en",
TenantID: "tenant-456",
Groups: []string{"admin", "editor"},
}),
)Inside a go-json program, session fields are available as:
session.user_idsession.localesession.tenant_idsession.groups
Extensions let your host application inject custom functions, nested namespaces, and constants into the go-json runtime. This is the primary mechanism for giving programs access to your application's domain logic.
rt := gojson.NewRuntime(
gojson.WithoutIO(),
gojson.WithExtension("myapp", gojson.Extension{
Functions: map[string]any{
"getUser": func(id string) (map[string]any, error) {
return map[string]any{"id": id, "name": "Alice"}, nil
},
"db": map[string]any{
"query": func(sql string, args ...any) ([]map[string]any, error) {
// Nested namespace: accessed as myapp.db.query(...)
return nil, nil
},
},
},
}),
)Programs import extensions with the ext: prefix:
{
"import": { "app": "ext:myapp" },
"steps": [
{ "let": "user", "expr": "app.getUser('user-123')" },
{ "let": "rows", "expr": "app.db.query('SELECT * FROM users')" }
]
}When a value in the Functions map is itself a map[string]any, it creates a nested namespace. This enables dotted access patterns like app.db.query(...) — organize your extension API however makes sense for your domain.
By default, go-json programs have no I/O access. You must explicitly opt in.
import goio "github.com/bitcode-framework/go-json/io"rt := gojson.NewRuntime(
gojson.WithIO(goio.All()),
)
defer rt.Close() // close I/O resources when doneOnly enable the modules you need:
rt := gojson.NewRuntime(
gojson.WithIO(goio.HTTP(), goio.FS()),
)
defer rt.Close()Lock down I/O with a security configuration:
rt := gojson.NewRuntime(
gojson.WithIO(goio.All()),
gojson.WithIOSecurity(&goio.SecurityConfig{
FS: goio.FSSecurityConfig{
AllowedPaths: []string{"/tmp/sandbox"},
AllowWrite: true,
MaxFileSize: 1024 * 1024, // 1 MB
},
}),
)
defer rt.Close()Important: Always call
rt.Close()when you're done with a runtime that has I/O enabled, to release underlying resources.
MongoDB and Redis modules use real drivers (go.mongodb.org/mongo-driver/v2 and github.com/redis/go-redis/v9). Connection is lazy — established on first operation using the URI from security config:
sec := goio.DefaultSecurityConfig()
sec.Mongo.DefaultURI = "mongodb://localhost:27017"
sec.Redis.DefaultURI = "redis://localhost:6379"
rt := gojson.NewRuntime(
gojson.WithIO(goio.Mongo(sec), goio.Redis(sec)),
)
defer rt.Close()Cache provides in-memory key-value storage with TTL (no external dependencies). Email provides SMTP sending with STARTTLS (configured via environment variables or SetConfig()):
rt := gojson.NewRuntime(
gojson.WithIO(goio.Cache(nil), goio.Email(nil)),
)
defer rt.Close()With security limits:
sec := goio.DefaultSecurityConfig()
sec.Cache = goio.CacheSecurityConfig{
MaxEntries: 5000,
MaxValueSize: 512 * 1024, // 512 KB
MaxTTL: 3600, // 1 hour max
}
sec.Email = goio.EmailSecurityConfig{
AllowedRecipients: []string{"*@company.com"},
BlockedDomains: []string{"competitor.com"},
MaxRecipients: 10,
}
rt := gojson.NewRuntime(
gojson.WithIO(goio.Cache(sec), goio.Email(sec)),
)
defer rt.Close()For cases where you need to evaluate a standalone expression without compiling a full program, use the EvalExpr family of functions. These are lightweight, thread-safe, and use a shared singleton engine with compilation caching.
import "github.com/bitcode-framework/go-json/runtime"
result, err := runtime.EvalExpr("price * quantity * (1 - discount/100)", map[string]any{
"price": 100.0,
"quantity": 5,
"discount": 10.0,
})
// result = 450.0// Boolean result
ok, err := runtime.EvalExprBool("age >= 18 && status == 'active'", map[string]any{
"age": 25,
"status": "active",
})
// Float result
total, err := runtime.EvalExprFloat("price * 1.1", map[string]any{
"price": 100.0,
})These are useful for rule engines, dynamic configuration, computed fields, and anywhere you need user-defined expressions without the overhead of a full program.
Implement the Debugger interface to get step-level visibility into program execution:
type Debugger interface {
OnStep(info StepInfo) DebugAction
OnVariable(name string, value any, scope string)
OnError(err error, step int)
OnFunctionCall(name string, args map[string]any)
OnFunctionReturn(name string, result any)
}type MyDebugger struct{}
func (d *MyDebugger) OnStep(info gojson.StepInfo) gojson.DebugAction {
fmt.Printf("Step %d: %s\n", info.Index, info.Type)
return gojson.Continue // or gojson.StepOver, gojson.StepInto, gojson.Abort
}
func (d *MyDebugger) OnVariable(name string, value any, scope string) {
fmt.Printf(" %s.%s = %v\n", scope, name, value)
}
func (d *MyDebugger) OnError(err error, step int) {
fmt.Printf(" ERROR at step %d: %v\n", step, err)
}
func (d *MyDebugger) OnFunctionCall(name string, args map[string]any) {
fmt.Printf(" CALL %s(%v)\n", name, args)
}
func (d *MyDebugger) OnFunctionReturn(name string, result any) {
fmt.Printf(" RETURN %s -> %v\n", name, result)
}
// Attach to runtime
rt := gojson.NewRuntime(
gojson.WithRuntimeDebugger(&MyDebugger{}),
)Implement the Logger interface for custom log output:
type Logger interface {
Log(level, message string, data map[string]any)
}The default logger prints to stdout with timestamps. Replace it to integrate with your application's logging infrastructure:
type AppLogger struct {
logger *slog.Logger
}
func (l *AppLogger) Log(level, message string, data map[string]any) {
l.logger.Info(message, "level", level, "data", data)
}
rt := gojson.NewRuntime(
gojson.WithRuntimeLogger(&AppLogger{logger: slog.Default()}),
)Enable tracing to capture a detailed timeline of every step in a program's execution:
rt := gojson.NewRuntime(
gojson.WithRuntimeTrace(true),
)
result, err := rt.Execute(program, input)
if err != nil {
log.Fatal(err)
}
for _, entry := range result.Trace {
fmt.Printf("Step %d: %s (%dμs)\n", entry.Step, entry.Type, entry.DurationUs)
}| Field | Type | Description |
|---|---|---|
Step |
int |
Step index in the program |
Type |
string |
Step type (let, set, if, while, etc.) |
DurationUs |
int64 |
Execution time for this step in microseconds |
Var |
string |
Variable name (for let/set steps) |
Value |
any |
Resulting value |
Condition |
any |
Condition result (for if/while steps) |
Result |
any |
Step result |
Tracing adds overhead. Enable it for development and diagnostics, not production hot paths.
go-json returns structured errors of type *lang.GoJSONError. These provide rich context for diagnosing issues.
import "github.com/bitcode-framework/go-json/lang"
result, err := rt.Execute(program, input)
if err != nil {
if gjErr, ok := err.(*lang.GoJSONError); ok {
fmt.Println("Code: ", gjErr.Code)
fmt.Println("Category: ", gjErr.Category) // "compile", "runtime", or "limit"
fmt.Println("Message: ", gjErr.Message)
fmt.Println("Step: ", gjErr.Step)
fmt.Println("Function: ", gjErr.Function)
fmt.Println("Stack: ", gjErr.Stack)
fmt.Println("Context: ", gjErr.Context)
fmt.Println("Suggestions:", gjErr.Suggestions)
fmt.Println("Fix: ", gjErr.Fix)
} else {
// Non-GoJSON error (e.g., I/O failure)
fmt.Println("Error:", err)
}
}| Field | Type | Description |
|---|---|---|
Code |
string |
Machine-readable error code |
Category |
string |
"compile", "runtime", or "limit" |
Message |
string |
Human-readable error message |
Step |
int |
Step index where the error occurred |
Function |
string |
Function name (if inside a function call) |
Stack |
[]string |
Call stack at the point of failure |
Context |
map |
Additional context about the error |
Suggestions |
[]string |
Suggested fixes |
Fix |
string |
Specific fix recommendation |
The Bitcode bridge pattern is the recommended approach for production applications. Instead of giving programs raw I/O access, you disable I/O entirely and inject a controlled bridge through extensions. This gives you full control over what programs can do.
rt := gojson.NewRuntime(
gojson.WithStdlib(reg.All()),
gojson.WithStdlibEnv(reg.EnvVars()),
gojson.WithoutIO(), // no raw I/O
gojson.WithExtension("bc", gojson.Extension{ // inject your bridge
Functions: map[string]any{
"model": func(name string) any {
// Return a model instance from your ORM
return nil
},
"db": map[string]any{
"query": func(sql string, args ...any) ([]map[string]any, error) { return nil, nil },
"execute": func(sql string, args ...any) (int64, error) { return 0, nil },
},
"http": map[string]any{
"get": func(url string) (map[string]any, error) { return nil, nil },
"post": func(url string, body any) (map[string]any, error) { return nil, nil },
},
"cache": map[string]any{
"get": func(key string) (any, error) { return nil, nil },
"set": func(key string, value any, ttl int) error { return nil },
"del": func(key string) error { return nil },
},
"log": func(level, msg string) { /* your logger */ },
"emit": func(event string, data any) { /* your event bus */ },
},
}),
gojson.WithLimits(limits),
gojson.WithSession(session),
)Programs use the bridge through a standard import:
{
"import": { "bc": "ext:bc" },
"steps": [
{ "let": "users", "expr": "bc.db.query('SELECT * FROM users WHERE active = ?', true)" },
{ "do": "bc.cache.set('active_users', users, 300)" },
{ "do": "bc.log('info', 'Cached ' + string(len(users)) + ' active users')" },
{ "return": "users" }
]
}- Security — Programs can only call functions you explicitly provide. No filesystem access, no arbitrary HTTP, no shell execution.
- Observability — Every bridge function is your code. Add logging, metrics, and tracing at the boundary.
- Testability — Swap the bridge implementation in tests to mock external dependencies.
- Consistency — All programs interact with your infrastructure through a single, well-defined API.