Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 83 additions & 34 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,37 +1,86 @@
.PHONY: build build-debug clean

build:
@echo "Configuring and building qjs..."
cd qjswasm/quickjs && \
rm -rf build && \
cmake -B build \
-DQJS_BUILD_LIBC=ON \
-DQJS_BUILD_CLI_WITH_MIMALLOC=OFF \
-DCMAKE_TOOLCHAIN_FILE=/opt/wasi-sdk/share/cmake/wasi-sdk.cmake \
-DCMAKE_PROJECT_INCLUDE=../qjswasm.cmake
@echo "Building qjs target..."
make -C qjswasm/quickjs/build qjswasm -j$(nproc)
@echo "Copying build/qjswasm to top-level as qjs.wasm..."
cp qjswasm/quickjs/build/qjswasm qjs.wasm

wasm-opt -O3 qjs.wasm -o qjs.wasm

build-debug:
@echo "Configuring and building qjs with runtime address debug..."
cd qjswasm/quickjs && \
rm -rf build && \
cmake -B build \
-DQJS_BUILD_LIBC=ON \
-DQJS_BUILD_CLI_WITH_MIMALLOC=OFF \
-DQJS_DEBUG_RUNTIME_ADDRESS=ON \
-DCMAKE_TOOLCHAIN_FILE=/opt/wasi-sdk/share/cmake/wasi-sdk.cmake \
-DCMAKE_PROJECT_INCLUDE=../qjswasm.cmake
@echo "Building qjs target..."
make -C qjswasm/quickjs/build qjswasm -j$(nproc)
@echo "Copying build/qjswasm to top-level as qjs.wasm..."
cp qjswasm/quickjs/build/qjswasm qjs.wasm

wasm-opt -O3 qjs.wasm -o qjs.wasm
.PHONY: build build-debug clean apply-patches clean-patches

# Apply all patches from qjswasm/patches/ to quickjs submodule
apply-patches:
@echo "Applying QuickJS patches..."
@cd qjswasm/quickjs && git checkout quickjs.c
@for patch in qjswasm/patches/*.patch; do \
if [ -f "$$patch" ]; then \
echo " Applying $$(basename $$patch)..."; \
cd qjswasm/quickjs && git apply "../patches/$$(basename $$patch)" || exit 1; \
cd ../..; \
fi \
done
@echo "All patches applied successfully"

# Clean up applied patches (restore original files)
clean-patches:
@echo "Cleaning up applied patches..."
@cd qjswasm/quickjs && git checkout quickjs.c
@echo "Patches cleaned up"

# Run QuickJS API tests before building WASM binary
# This verifies that our patches compile correctly and basic functionality works
test-quickjs: apply-patches
@echo "Running QuickJS API tests..."
@cd qjswasm/quickjs && \
rm -rf build && \
cmake -B build \
-DQJS_BUILD_LIBC=ON \
-DQJS_BUILD_CLI_WITH_MIMALLOC=OFF \
-DCMAKE_TOOLCHAIN_FILE=/opt/wasi-sdk/share/cmake/wasi-sdk.cmake >/dev/null 2>&1 && \
make -C build api-test -j$(shell nproc 2>/dev/null || sysctl -n hw.ncpu || echo 4) >/dev/null 2>&1 && \
build/api-test
@echo "✅ QuickJS API tests passed!"
@echo ""

# Run full QuickJS test262 suite (slow, for comprehensive testing)
test-quickjs-full: apply-patches
@echo "Running full QuickJS test262 suite (this may take several minutes)..."
cd qjswasm/quickjs && \
rm -rf build && \
cmake -B build \
-DQJS_BUILD_LIBC=ON \
-DQJS_BUILD_CLI_WITH_MIMALLOC=OFF \
-DCMAKE_TOOLCHAIN_FILE=/opt/wasi-sdk/share/cmake/wasi-sdk.cmake && \
make -C build run-test262 -j$(shell nproc 2>/dev/null || sysctl -n hw.ncpu || echo 4) && \
build/run-test262 -c tests.conf
@echo "✅ Full test suite passed!"

build: test-quickjs
@echo "Configuring and building qjs..."
cd qjswasm/quickjs && \
rm -rf build && \
cmake -B build \
-DQJS_BUILD_LIBC=ON \
-DQJS_BUILD_CLI_WITH_MIMALLOC=OFF \
-DCMAKE_TOOLCHAIN_FILE=/opt/wasi-sdk/share/cmake/wasi-sdk.cmake \
-DCMAKE_PROJECT_INCLUDE=../qjswasm.cmake
@echo "Building qjs target..."
make -C qjswasm/quickjs/build qjswasm -j$(nproc)
@echo "Copying build/qjswasm to top-level as qjs.wasm..."
cp qjswasm/quickjs/build/qjswasm qjs.wasm

wasm-opt -O3 qjs.wasm -o qjs.wasm
$(MAKE) clean-patches

build-debug: apply-patches
@echo "Configuring and building qjs with runtime address debug..."
cd qjswasm/quickjs && \
rm -rf build && \
cmake -B build \
-DQJS_BUILD_LIBC=ON \
-DQJS_BUILD_CLI_WITH_MIMALLOC=OFF \
-DQJS_DEBUG_RUNTIME_ADDRESS=ON \
-DCMAKE_TOOLCHAIN_FILE=/opt/wasi-sdk/share/cmake/wasi-sdk.cmake \
-DCMAKE_PROJECT_INCLUDE=../qjswasm.cmake
@echo "Building qjs target..."
make -C qjswasm/quickjs/build qjswasm -j$(nproc)
@echo "Copying build/qjswasm to top-level as qjs.wasm..."
cp qjswasm/quickjs/build/qjswasm qjs.wasm

wasm-opt -O3 qjs.wasm -o qjs.wasm
$(MAKE) clean-patches

clean:
@echo "Cleaning build directory..."
Expand Down
13 changes: 5 additions & 8 deletions common.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"hash/fnv"
"math"
"reflect"
"slices"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -554,13 +555,9 @@ func NumericBoundsCheck(floatVal float64, targetKind reflect.Kind) error {

// IsTypedArray returns true if the input is TypedArray or DataView.
func IsTypedArray(input *Value) bool {
for _, typeName := range typedArrayTypes {
if input.IsGlobalInstanceOf(typeName) {
return true
}
}

return false
return slices.ContainsFunc(typedArrayTypes, func(typeName string) bool {
return input.IsGlobalInstanceOf(typeName)
})
}

// processTempValue validates if temp is a valid result for the given T type.
Expand Down Expand Up @@ -717,7 +714,7 @@ func createGoObjectTarget[T any](input ObjectOrMap, samples ...T) (

target = reflect.TypeOf(sample)
if target == nil {
target = reflect.TypeOf(map[string]any{})
target = reflect.TypeFor[map[string]any]()
}

temp = reflect.New(target).Interface()
Expand Down
10 changes: 6 additions & 4 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import (
"fmt"
"reflect"
"runtime/debug"
"strings"
)

var (
ErrRType = reflect.TypeOf((*error)(nil)).Elem()
ErrRType = reflect.TypeFor[error]()
ErrZeroRValue = reflect.Zero(ErrRType)
ErrCallFuncOnNonObject = errors.New("cannot call function on non-object")
ErrNotAnObject = errors.New("value is not an object")
Expand Down Expand Up @@ -40,15 +41,16 @@ func combineErrors(errs ...error) error {
return nil
}

var errStr string
var errStr strings.Builder

for _, err := range errs {
if err != nil {
errStr += err.Error() + "\n"
errStr.WriteString(err.Error())
errStr.WriteString("\n")
}
}

return errors.New(errStr)
return errors.New(errStr.String())
}

func newMaxLengthExceededErr(request uint, maxLen int64, index int) error {
Expand Down
18 changes: 17 additions & 1 deletion eval.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package qjs

import "fmt"

func load(c *Context, file string, flags ...EvalOptionFunc) (*Value, error) {
if file == "" {
return nil, ErrInvalidFileName
Expand All @@ -18,7 +20,21 @@ func load(c *Context, file string, flags ...EvalOptionFunc) (*Value, error) {
return normalizeJsValue(c, result)
}

func eval(c *Context, file string, flags ...EvalOptionFunc) (*Value, error) {
func eval(c *Context, file string, flags ...EvalOptionFunc) (value *Value, err error) {
// Recover from WASM panics (e.g., module closed due to context cancellation)
// This provides graceful error handling when CloseOnContextDone closes the module
defer func() {
if r := recover(); r != nil {
value = nil
// Check if context was cancelled
if c.Context != nil && c.Err() != nil {
err = fmt.Errorf("execution interrupted (context cancelled): %w", c.Err())
} else {
err = fmt.Errorf("execution interrupted (WASM panic): %v", r)
}
}
}()

if file == "" {
return nil, ErrInvalidFileName
}
Expand Down
8 changes: 7 additions & 1 deletion functojs.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,13 @@ func CreateVariadicSlice(jsArgs []*Value, sliceType reflect.Type, fixedArgsCount
return reflect.Value{}, newArgConversionErr(fixedArgsCount+i, err)
}

variadicSlice.Index(i).Set(goVal)
// Handle JavaScript null/undefined which result in invalid reflect.Value
// Set zero value for both interface{}/any and concrete types
if !goVal.IsValid() {
variadicSlice.Index(i).Set(reflect.Zero(varArgType))
} else {
variadicSlice.Index(i).Set(goVal)
}
}

return variadicSlice, nil
Expand Down
92 changes: 92 additions & 0 deletions functojs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -716,3 +716,95 @@ func TestCreateNonNilSample(t *testing.T) {
})
}
}

// TestVariadicFunctionWithNullUndefined is a regression test for the bug where
// JavaScript null/undefined values passed to Go variadic functions caused panics.
//
// Bug: JavaScript null/undefined convert to invalid reflect.Value, which caused
// "reflect: call of reflect.Value.Set on zero Value" panic in CreateVariadicSlice.
//
// Fix: Check for invalid reflect.Value before calling Set(), use reflect.Zero() instead.
func TestVariadicFunctionWithNullUndefined(t *testing.T) {
runtime := must(qjs.New(qjs.Option{}))
defer runtime.Close()
ctx := runtime.Context()

// Create a variadic function that logs all arguments
logFunc := func(args ...any) string {
result := fmt.Sprintf("received %d args", len(args))
for i, arg := range args {
result += fmt.Sprintf(", arg[%d]=%v (nil=%v)", i, arg, arg == nil)
}
return result
}

// Register function
logValue := must(qjs.ToJsValue(ctx, map[string]any{
"log": logFunc,
}))
ctx.Global().SetPropertyStr("api", logValue)

tests := []struct {
name string
code string
wantErr bool
contains string // Expected substring in result
}{
{
name: "null_only",
code: `api.log(null)`,
wantErr: false,
contains: "received 1 args",
},
{
name: "undefined_only",
code: `api.log(undefined)`,
wantErr: false,
contains: "received 1 args",
},
{
name: "null_and_undefined",
code: `api.log(null, undefined)`,
wantErr: false,
contains: "received 2 args",
},
{
name: "mixed_with_strings",
code: `api.log("hello", null, "world", undefined)`,
wantErr: false,
contains: "received 4 args",
},
{
name: "mixed_with_numbers",
code: `api.log(42, null, 3.14, undefined)`,
wantErr: false,
contains: "received 4 args",
},
{
name: "only_undefined_multiple",
code: `api.log(undefined, undefined, undefined)`,
wantErr: false,
contains: "received 3 args",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ctx.Eval("test.js", qjs.Code(tt.code))
if tt.wantErr {
assert.Error(t, err)
return
}

require.NoError(t, err, "Variadic function with null/undefined should not error")
require.NotNil(t, result, "Result should not be nil")
defer result.Free()

// Verify result contains expected substring
resultStr := result.String()
assert.Contains(t, resultStr, tt.contains, "Result should contain expected substring")

t.Logf("Test %s result: %s", tt.name, resultStr)
})
}
}
6 changes: 4 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
module github.com/fastschema/qjs

go 1.22.0
go 1.23.0

toolchain go1.24.7

require (
github.com/stretchr/testify v1.11.1
github.com/tetratelabs/wazero v1.9.0
github.com/tetratelabs/wazero v1.10.1
)

require (
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
8 changes: 7 additions & 1 deletion jstogo.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import (
)

func ToGoValue[T any](input *Value, samples ...T) (v T, err error) {
// Check if input is null/undefined BEFORE trying to access properties
if input.IsNull() || input.IsUndefined() {
// For null/undefined, return zero value of the target type
return v, nil
}

registryID := input.GetPropertyStr("__registry_id")
if !registryID.IsUndefined() && !registryID.IsNull() {
registryVal, ok := input.context.runtime.registry.Get(uint64(registryID.Int64()))
Expand Down Expand Up @@ -548,7 +554,7 @@ func jsObjectToGo[T any](

targetType := reflect.TypeOf(sample)
if targetType == nil {
targetType = reflect.TypeOf(map[string]any{})
targetType = reflect.TypeFor[map[string]any]()
}

if targetType.Kind() == reflect.Map {
Expand Down
Loading
Loading