Files
poiesis/plans/BUILTIN.md
2026-01-27 12:55:55 -05:00

325 lines
7.5 KiB
Markdown

# Builtin System Design
## Overview
Type-safe builtin system for exposing Go functions to TypeScript/JavaScript with automatic type conversion and defaults support.
## Core Design
### Single Argument Pattern
All builtins accept a **single struct argument** on the Go side, but are called as **multi-argument functions** on the JavaScript side.
```go
type FetchArgs struct {
URL string `json:"url"`
Options *FetchOptions `json:"options"`
}
func Fetch(args FetchArgs) (*FetchResult, error) {
// Implementation
}
// JavaScript calls:
// fetch("https://example.com")
// fetch("https://example.com", { method: "POST" })
```
**Mapping Rules:**
- JavaScript arguments map **positionally** to struct fields in order
- Field names in JavaScript come from `json:""` struct tag, or field name if tag is omitted
- Missing JavaScript arguments only allowed for **pointer fields** (non-pointer fields must always be provided)
### Default Values
Argument structs **must** implement `Defaults()` receiver method to provide default values:
```go
type FetchOptions struct {
Method string `json:"method"`
Headers map[string]string `json:"headers"`
}
func (f *FetchOptions) Defaults() *FetchOptions {
if f.Method == "" {
f.Method = "GET"
}
return f
}
// If called with just URL, Options will be nil initially,
// then we create default: &FetchOptions{Method: "GET"}
```
**Defaults flow:**
1. Create zero-value struct
2. Fill in provided JavaScript arguments positionally
3. Call `Defaults()` on the struct to fill in remaining defaults
### Empty Arguments
Zero-argument builtins must still use a struct (can be empty):
```go
// Helper type for no-argument builtins
type EmptyArgs struct {}
func Ping(args EmptyArgs) bool {
return true
}
// JavaScript: ping()
```
## Registration API
```go
package builtin
// RegisterBuiltin registers a Go function as a builtin
// The function must accept a single struct argument T and return (R, error) or R
func RegisterBuiltin[T any, R any](name string, fn func(T) (R, error))
```
**Usage:**
```go
func init() {
builtin.RegisterBuiltin("fetch", Fetch)
builtin.RegisterBuiltin("add", Add)
}
```
## Type Conversion
### JavaScript → Go
Automatic conversion from goja values to Go struct fields:
| JavaScript | Go Type |
|------------|---------|
| string | string |
| number | int, int8-64, uint, uint8-64, float32, float64 |
| boolean | bool |
| object/map | map[string]any |
| null/undefined | nil (for pointer fields only) |
**Struct Field Conversion:**
- Primitives: converted by type
- Maps: `map[string]any` → Go map with string keys
- Nested structs: Recursive conversion to nested struct
### Go → JavaScript (Return Types)
Automatic conversion from Go return values to JavaScript:
| Go Type | JavaScript |
|---------|------------|
| string, number, bool | primitive |
| map[string]any | object |
| map[string]string | object |
| []any | array |
| struct | object with fields from json tags |
**Struct Return Types:**
- Auto-converted to JavaScript objects
- Field names from `json:""` struct tags
- Nested structs become nested objects
- No custom converters needed
**Example:**
```go
type FetchResult struct {
OK bool `json:"ok"`
Status int `json:"status"`
Body string `json:"body"`
}
// Returns: { ok: true, status: 200, body: "..." }
```
## Error Handling
Go errors are automatically converted to JavaScript exceptions:
```go
func Fetch(args FetchArgs) (*FetchResult, error) {
if someError {
return nil, fmt.Errorf("fetch failed")
}
return result, nil
}
// JavaScript: fetch calls that error will throw
```
## TypeScript Declaration Generation
TypeScript definitions are auto-generated from Go function signatures:
```go
// Go:
func Fetch(args FetchArgs) (*FetchResult, error)
// Generated TypeScript:
declare function fetch(url: string, options?: FetchOptions): FetchResult | never;
```
**Type mapping:**
- `string``string`
- `int/int8-64/uint/uint8-64/float32/float64``number`
- `bool``boolean`
- `*T``T | null` (or `T` if optional)
- `map[string]any``Record<string, any>`
- `struct` → Interface with fields
**Optional parameters:**
- Pointer fields become optional parameters with `T | null`
- Non-pointer fields are required
## Implementation Notes
### Reflection-Based Wrapping
```go
func createWrapper[T any, R any](fn func(T) (R, error)) func(*goja.Runtime, goja.FunctionCall) goja.Value {
return func(vm *goja.Runtime, call goja.FunctionCall) goja.Value {
// 1. Create zero-value struct
var args T
argsValue := reflect.ValueOf(&args).Elem()
// 2. Map JavaScript args to struct fields positionally
for i := 0; i < min(len(call.Arguments), argsValue.NumField()); i++ {
jsArg := call.Arguments[i]
field := argsValue.Field(i)
// Skip nil/undefined if field is pointer
if goja.IsUndefined(jsArg) || goja.IsNull(jsArg) {
if field.Kind() == reflect.Ptr {
continue
}
}
// Convert and assign
converted, err := convertJSValueToGo(vm, jsArg, field.Type())
if err != nil {
panic(err)
}
field.Set(reflect.ValueOf(converted))
}
// 3. Call Defaults() if defined
if defaults, ok := args.(interface{ Defaults() T }); ok {
args = defaults.Defaults()
}
// 4. Call the Go function
result, err := fn(args)
if err != nil {
panic(err)
}
// 5. Convert return value to JavaScript
return convertGoValueToJS(vm, reflect.ValueOf(result))
}
}
```
### Field Name Extraction
```go
func getFieldName(field reflect.StructField) string {
jsonTag := field.Tag.Get("json")
if jsonTag != "" && jsonTag != "-" {
name, _, _ := strings.Cut(jsonTag, ",")
return name
}
return field.Name
}
```
## Migration Examples
### Old → New
**Old (current system):**
```go
func add(a, b int) int {
return a + b
}
// Register: builtin.RegisterBuiltin("add", add)
// JS: add(5, 10)
```
**New system:**
```go
type AddArgs struct {
A int `json:"a"`
B int `json:"b"`
}
func add(args AddArgs) int {
return args.A + args.B
}
// Register: builtin.RegisterBuiltin("add", add)
// JS: add(5, 10) // positional
// JS: add(a=5, b=10) // named (if supported)
```
**Old fetch:**
```go
func Fetch(url string, options map[string]any) (*FetchResult, error)
// Requires custom converter for result
```
**New fetch:**
```go
type FetchArgs struct {
URL string `json:"url"`
Options *FetchOptions `json:"options"`
}
type FetchOptions struct {
Method string `json:"method"`
Headers map[string]string `json:"headers"`
}
func (o *FetchOptions) Defaults() *FetchOptions {
if o.Method == "" {
o.Method = "GET"
}
return o
}
type FetchResult struct {
OK bool `json:"ok"`
Status int `json:"status"`
Body string `json:"body"`
}
func Fetch(args FetchArgs) (*FetchResult, error) {
// Implementation - no custom converter needed
}
// JS: fetch("https://example.com")
// JS: fetch("https://example.com", { method: "POST" })
```
## Testing
All existing tests must pass after migration:
- `internal/runtime/runtime_test.go`
- `internal/runtime/standard/fetch_test.go`
- `test_data/*.ts` test files
Verify:
- TypeScript definitions are correct
- Positional argument mapping works
- Defaults are applied correctly
- Named JSON tags work
- Error handling propagates
- Return value conversion works