325 lines
7.5 KiB
Markdown
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
|