diff --git a/.gitignore b/.gitignore index 6782090..6c90aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ coverage .tool-versions releases +.idea/ \ No newline at end of file diff --git a/common.go b/common.go index 84c09ab..b79e1a2 100644 --- a/common.go +++ b/common.go @@ -6,6 +6,7 @@ import ( "hash/fnv" "math" "reflect" + "slices" "strconv" "strings" "sync" @@ -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. @@ -715,9 +712,9 @@ func createGoObjectTarget[T any](input ObjectOrMap, samples ...T) ( obj = obj.ToMap() } - target = reflect.TypeOf(sample) + target = reflect.TypeFor[T]() if target == nil { - target = reflect.TypeOf(map[string]any{}) + target = reflect.TypeFor[map[string]any]() } temp = reflect.New(target).Interface() diff --git a/context.go b/context.go index 6c4a226..51abe37 100644 --- a/context.go +++ b/context.go @@ -382,6 +382,15 @@ func (c *Context) Invoke(fn *Value, this *Value, args ...*Value) (*Value, error) return normalizeJsValue(c, result) } +func (c *Context) defaultModuleLoader(moduleName string) uint64 { + moduleNameHandle := c.NewStringHandle(moduleName) + defer moduleNameHandle.Free() + + result := c.runtime.Call("QJS_ModuleLoader", c.Raw(), moduleNameHandle.Raw(), 0) + + return result.raw +} + // createJsCallArgs marshals Go Value arguments to WASM memory for JavaScript calls. func createJsCallArgs(c *Context, args ...*Value) (uint64, uint64) { var argvPtr uint64 diff --git a/errors.go b/errors.go index 16b467a..f3ad67e 100644 --- a/errors.go +++ b/errors.go @@ -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") @@ -40,15 +41,16 @@ func combineErrors(errs ...error) error { return nil } - var errStr string + var builder strings.Builder for _, err := range errs { if err != nil { - errStr += err.Error() + "\n" + builder.WriteString(err.Error()) + builder.WriteString("\n") } } - return errors.New(errStr) + return errors.New(builder.String()) } func newMaxLengthExceededErr(request uint, maxLen int64, index int) error { diff --git a/jstogo.go b/jstogo.go index 814a93d..3bc7517 100644 --- a/jstogo.go +++ b/jstogo.go @@ -546,9 +546,9 @@ func jsObjectToGo[T any]( _, sample := createTemp(samples...) - targetType := reflect.TypeOf(sample) + targetType := reflect.TypeFor[T]() if targetType == nil { - targetType = reflect.TypeOf(map[string]any{}) + targetType = reflect.TypeFor[map[string]any]() } if targetType.Kind() == reflect.Map { diff --git a/jstogo_test.go b/jstogo_test.go index 11dfa05..011b373 100644 --- a/jstogo_test.go +++ b/jstogo_test.go @@ -2076,7 +2076,7 @@ func TestJsFuncToGo(t *testing.T) { } else { // Check if it's an error type returnType := sampleFnType.Out(0) - if returnType.Implements(reflect.TypeOf((*error)(nil)).Elem()) { + if returnType.Implements(reflect.TypeFor[error]()) { // Single error return assert.Nil(t, results[0].Interface()) } else { diff --git a/proxy.go b/proxy.go index 6c4103b..0a07769 100644 --- a/proxy.go +++ b/proxy.go @@ -40,7 +40,7 @@ func (r *ProxyRegistry) Register(fn any) uint64 { } // Get retrieves a function by its ID. -// Returns the function and true if found, nil and false otherwise. +// Returns the function and true if found, nil, and false otherwise. // This method is thread-safe and can be called concurrently. func (r *ProxyRegistry) Get(id uint64) (any, bool) { if id == 0 { @@ -112,6 +112,46 @@ type JsFunctionProxy = func( argv uint32, ) (rs uint64) +// ModuleLoaderFunc is a Go function that loads JavaScript modules. +// It receives the module name and should return the module's source code as a string, +// or an error if the module cannot be loaded. +// +// Return values: +// - (source, nil): The module source code will be compiled and loaded +// - ("", error): The error will be thrown as a JavaScript exception +// - ("", nil): Falls back to the default file system loader +// +// Example: +// +// func(ctx *Context, moduleName string) (string, error) { +// if source, ok := myModules[moduleName]; ok { +// return source, nil +// } +// return "", nil // Fall back to default loader +// } +type ModuleLoaderFunc func(ctx *Context, moduleName string) (string, error) + +// JsModuleLoaderProxy is the Go host function for module loading that will be imported by the WASM module. +// It corresponds to the following C declaration: +// +// __attribute__((import_module("env"), import_name("jsModuleLoaderProxy"))) +// extern uint64_t jsModuleLoaderProxy(uint32_t ctx, uint32_t module_name, uint64_t callback_id); +// +// Parameters: +// - jsCtx: JSContext pointer (as uint32) +// - moduleNamePtr: pointer to module name string in WASM memory +// - callbackID: ID of the registered Go callback function +// +// Returns: +// - JSModuleDef pointer as uint64 (or 0 on error) +type JsModuleLoaderProxy = func( + ctx context.Context, + module api.Module, + jsCtx uint32, + moduleNamePtr uint32, + callbackID uint64, +) uint64 + // createFuncProxyWithRegistry creates a WASM function proxy that bridges JavaScript function calls to Go functions. // It handles parameter extraction, error recovery, and result conversion between JS and Go. func createFuncProxyWithRegistry(registry *ProxyRegistry) JsFunctionProxy { @@ -255,3 +295,92 @@ func readArgsFromWasmMem(mem api.Memory, argc uint32, argv uint32) []uint64 { return args } + +// createModuleLoaderProxyWithRegistry creates a WASM module loader proxy that bridges QuickJS module loading to Go +// functions. +func createModuleLoaderProxyWithRegistry(registry *ProxyRegistry, runtime *Runtime) JsModuleLoaderProxy { + return func( + _ context.Context, + module api.Module, + _ uint32, + moduleNamePtr uint32, + callbackID uint64, + ) uint64 { + // Read the module name from WASM memory + moduleName := readStringFromWasmMem(module.Memory(), moduleNamePtr) + + // Get the Go callback function + fn, ok := registry.Get(callbackID) + if !ok { + // No callback registered - return 0 (NULL) + return 0 + } + + moduleLoader, ok := fn.(ModuleLoaderFunc) + if !ok { + // Wrong type - return 0 (NULL) + return 0 + } + + // Get the context + ctx := runtime.Context() + + // Call the Go module loader function + defer func() { + if r := recover(); r != nil { + // Handle panic by setting an exception + ctx.ThrowError(AnyToError(r)) + } + }() + + source, err := moduleLoader(ctx, moduleName) + if err != nil { + // Throw error as JavaScript exception + ctx.ThrowError(err) + + return 0 + } + + if source == "" { + // Empty source - fall back to default file system loader + return ctx.defaultModuleLoader(moduleName) + } + + // Compile and load the module source code + value, err := ctx.Load(moduleName, Code(source), TypeModule()) + if err != nil { + // Compilation/loading failed - throw error + ctx.ThrowError(err) + + return 0 + } + + // Return JSModuleDef pointer as uint64 + return value.Raw() + } +} + +// readStringFromWasmMem reads a null-terminated C string from WASM memory. +func readStringFromWasmMem(mem api.Memory, ptr uint32) string { + if ptr == 0 { + return "" + } + + // Read bytes until we hit a null terminator + var bytes []byte + + offset := ptr + + for { + b, ok := mem.ReadByte(offset) + if !ok || b == 0 { + break + } + + bytes = append(bytes, b) + + offset++ + } + + return string(bytes) +} diff --git a/qjs.wasm b/qjs.wasm index b1f0d25..ceb74e8 100755 Binary files a/qjs.wasm and b/qjs.wasm differ diff --git a/qjswasm/module_loader.c b/qjswasm/module_loader.c new file mode 100644 index 0000000..0a70a32 --- /dev/null +++ b/qjswasm/module_loader.c @@ -0,0 +1,38 @@ +#include "qjs.h" + +#ifdef __wasm__ +// When compiling for WASM, declare the imported host function. +// The function is imported from the "env" module under the name "jsModuleLoaderProxy". +__attribute__((import_module("env"), import_name("jsModuleLoaderProxy"))) extern uint64_t jsModuleLoaderProxy(uint32_t ctx, uint32_t module_name, uint64_t callback_id); +#endif + +// The actual module loader function that will be called by QuickJS +// The callback_id is passed via the opaque pointer +JSModuleDef *GoModuleLoaderProxy(JSContext *ctx, const char *module_name, void *opaque) +{ +#ifdef __wasm__ + // Extract the callback_id from the opaque pointer + uint64_t callback_id = (uint64_t)(uintptr_t)opaque; + + if (callback_id == 0) + { + JS_ThrowInternalError(ctx, "Module loader callback not set"); + return NULL; + } + + // Call the Go callback through the imported host function + // The Go function compiles and loads the module, returning JSModuleDef* as uint64 + uint64_t result = jsModuleLoaderProxy((uint32_t)(uintptr_t)ctx, (uint32_t)(uintptr_t)module_name, callback_id); + + return (JSModuleDef *)(uintptr_t)result; +#else + JS_ThrowInternalError(ctx, "Module loader proxy not implemented for native builds"); + return NULL; +#endif +} + +void QJS_SetModuleLoaderCallback(QJSRuntime *qjs, uint64_t callback_id) +{ + // Pass the callback_id as the opaque pointer - this is the idiomatic C pattern + JS_SetModuleLoaderFunc(qjs->runtime, NULL, GoModuleLoaderProxy, (void *)(uintptr_t)callback_id); +} diff --git a/qjswasm/qjs.h b/qjswasm/qjs.h index d30e8e4..1e5fb49 100644 --- a/qjswasm/qjs.h +++ b/qjswasm/qjs.h @@ -144,5 +144,6 @@ JSValue QJS_NewArrayBufferCopy(JSContext *ctx, uint64_t addr, uint64_t len); JSValue QJS_Call(JSContext *ctx, JSValue func, JSValue this, int argc, uint64_t argv); JSValue QJS_NewProxyValue(JSContext *ctx, int64_t proxyId); QJSRuntime *QJS_GetRuntime(); +void QJS_SetModuleLoaderCallback(QJSRuntime *qjs, uint64_t callback_id); void initialize(); diff --git a/qjswasm/qjswasm.cmake b/qjswasm/qjswasm.cmake index 8fd8607..1a5bd4c 100644 --- a/qjswasm/qjswasm.cmake +++ b/qjswasm/qjswasm.cmake @@ -41,6 +41,7 @@ add_executable(qjswasm ../helpers.c ../proxy.c ../qjs.c + ../module_loader.c ) add_qjs_libc_if_needed(qjswasm) @@ -109,6 +110,7 @@ target_link_options(qjswasm PRIVATE "LINKER:--export=QJS_ThrowInternalError" "LINKER:--export=QJS_ModuleLoader" + "LINKER:--export=QJS_SetModuleLoaderCallback" "LINKER:--export=QJS_Load" "LINKER:--export=QJS_Eval" "LINKER:--export=QJS_Compile" diff --git a/runtime.go b/runtime.go index f099bc3..1bb77d0 100644 --- a/runtime.go +++ b/runtime.go @@ -124,10 +124,16 @@ func New(options ...Option) (runtime *Runtime, err error) { return nil, fmt.Errorf("failed to instantiate WASI: %w", err) } + // Create module loader proxy with registry and runtime + moduleLoaderProxy := createModuleLoaderProxyWithRegistry(proxyRegistry, runtime) + if _, err := runtime.wrt.NewHostModuleBuilder("env"). NewFunctionBuilder(). WithFunc(option.ProxyFunction). Export("jsFunctionProxy"). + NewFunctionBuilder(). + WithFunc(moduleLoaderProxy). + Export("jsModuleLoaderProxy"). Instantiate(option.Context); err != nil { return nil, fmt.Errorf("failed to setup host module: %w", err) } @@ -235,6 +241,51 @@ func (r *Runtime) Context() *Context { return r.context } +// SetModuleLoaderFunc sets a custom module loader function for this runtime. +// The provided Go function will be called whenever JavaScript code imports a module. +// +// The module loader receives the module name and should return: +// - (source, nil): Module source code to compile and load +// - ("", error): Error to throw as JavaScript exception +// - ("", nil): Fall back to default file system loader +// +// Example - Load modules from memory: +// +// modules := map[string]string{ +// "utils": "export const add = (a, b) => a + b;", +// "config": "export const API_URL = 'https://api.example.com';", +// } +// +// runtime.SetModuleLoaderFunc(func(ctx *qjs.Context, moduleName string) (string, error) { +// if source, ok := modules[moduleName]; ok { +// return source, nil // Return source to compile +// } +// return "", nil // Fall back to file system +// }) +// +// Example - Logging with delegation: +// +// runtime.SetModuleLoaderFunc(func(ctx *qjs.Context, moduleName string) (string, error) { +// fmt.Printf("Loading: %s\n", moduleName) +// return "", nil // Delegate to default loader +// }) +// +// Example - Access control: +// +// runtime.SetModuleLoaderFunc(func(ctx *qjs.Context, moduleName string) (string, error) { +// if !isAllowed(moduleName) { +// return "", fmt.Errorf("module '%s' not allowed", moduleName) +// } +// return "", nil // Load from file system +// }) +func (r *Runtime) SetModuleLoaderFunc(loaderFunc ModuleLoaderFunc) { + // Register the Go function in the proxy registry + callbackID := r.registry.Register(loaderFunc) + + // Set the module loader callback with the callback ID + r.call("QJS_SetModuleLoaderCallback", r.handle.raw, callbackID) +} + // Call invokes a WebAssembly function by name with the given arguments. func (r *Runtime) Call(name string, args ...uint64) *Handle { return NewHandle(r, r.call(name, args...)) diff --git a/runtime_moduleloader_test.go b/runtime_moduleloader_test.go new file mode 100644 index 0000000..3ad79ae --- /dev/null +++ b/runtime_moduleloader_test.go @@ -0,0 +1,144 @@ +package qjs + +import ( + "fmt" + "testing" +) + +// TestSetModuleLoaderFuncWithCustomLoader demonstrates creating a completely custom module loader +// that loads modules from memory +func TestSetModuleLoaderFuncWithCustomLoader(t *testing.T) { + rt, err := New() + if err != nil { + t.Fatalf("Failed to create runtime: %v", err) + } + defer rt.Close() + + // Create a custom module loader that provides modules from memory + moduleSource := map[string]string{ + "math-utils": ` + export function add(a, b) { return a + b; } + export function multiply(a, b) { return a * b; } + `, + "config": ` + export const API_URL = 'https://api.example.com'; + export const VERSION = '1.0.0'; + `, + } + + var loadedModules []string + + rt.SetModuleLoaderFunc(func(ctx *Context, moduleName string) (string, error) { + t.Logf("Custom loader called for: %s", moduleName) + loadedModules = append(loadedModules, moduleName) + + // Check if we have this module in our map + if source, exists := moduleSource[moduleName]; exists { + t.Logf(" → Returning source for '%s' from memory", moduleName) + // Return the source code - it will be compiled automatically + return source, nil + } + + // Module not found - delegate to default file system loader + t.Logf(" → Module '%s' not found in memory, delegating to file system", moduleName) + return "", nil + }) + + // Test loading virtual modules + ctx := rt.Context() + result, err := ctx.Eval("main.js", Code(` + import { add, multiply } from 'math-utils'; + import { API_URL, VERSION } from 'config'; + + const sum = add(5, 3); + const product = multiply(4, 7); + + export default { + sum: sum, + product: product, + api: API_URL, + version: VERSION + }; + `), TypeModule()) + + if err != nil { + t.Fatalf("Failed to evaluate module with in-memory compilation: %v", err) + } + defer result.Free() + + // Verify our custom modules were loaded + if len(loadedModules) != 2 { + t.Errorf("Expected 2 modules to be loaded, got %d: %v", len(loadedModules), loadedModules) + } + + // Verify the results + sum := result.GetPropertyStr("sum") + defer sum.Free() + if sum.Int64() != 8 { + t.Errorf("Expected sum to be 8, got: %d", sum.Int64()) + } + + product := result.GetPropertyStr("product") + defer product.Free() + if product.Int64() != 28 { + t.Errorf("Expected product to be 28, got: %d", product.Int64()) + } + + api := result.GetPropertyStr("api") + defer api.Free() + if api.String() != "https://api.example.com" { + t.Errorf("Expected API_URL to be 'https://api.example.com', got: %s", api.String()) + } + + version := result.GetPropertyStr("version") + defer version.Free() + if version.String() != "1.0.0" { + t.Errorf("Expected VERSION to be '1.0.0', got: %s", version.String()) + } + + t.Log("✓ In-memory module loading test passed!") + t.Logf("Successfully loaded virtual modules: %v", loadedModules) +} + +// TestSetModuleLoaderFuncWithError tests error handling in custom module loader +func TestSetModuleLoaderFuncWithError(t *testing.T) { + rt, err := New() + if err != nil { + t.Fatalf("Failed to create runtime: %v", err) + } + defer rt.Close() + + // Module loader that returns an error for specific modules + rt.SetModuleLoaderFunc(func(ctx *Context, moduleName string) (string, error) { + t.Logf("Custom loader called for: %s", moduleName) + + if moduleName == "forbidden" { + return "", fmt.Errorf("module '%s' is forbidden", moduleName) + } + + // Delegate to default + return "", nil + }) + + ctx := rt.Context() + + // Try to load a forbidden module + result, err := ctx.Eval("test.js", Code(` + import { something } from 'forbidden'; + export default something; + `), TypeModule()) + + if err == nil { + defer result.Free() + t.Fatal("Expected error when loading forbidden module, but got success") + } + + t.Logf("Got expected error: %v", err) + + // Verify the error message contains our custom error + if err.Error() == "" { + t.Error("Expected non-empty error message") + } + + t.Log("✓ Error handling test passed!") +} diff --git a/utils.go b/utils.go index 44892c4..11e1af3 100644 --- a/utils.go +++ b/utils.go @@ -16,12 +16,12 @@ func Min(a, b int) int { } func IsImplementError(rtype reflect.Type) bool { - return rtype.Implements(reflect.TypeOf((*error)(nil)).Elem()) + return rtype.Implements(reflect.TypeFor[error]()) } // IsImplementsJSONUnmarshaler checks if a type implements json.Unmarshaler. func IsImplementsJSONUnmarshaler(t reflect.Type) bool { - unmarshalerType := reflect.TypeOf((*json.Unmarshaler)(nil)).Elem() + unmarshalerType := reflect.TypeFor[json.Unmarshaler]() return t.Implements(unmarshalerType) || reflect.PointerTo(t).Implements(unmarshalerType) } @@ -29,6 +29,7 @@ func IsImplementsJSONUnmarshaler(t reflect.Type) bool { // GetGoTypeName creates a descriptive string for complex types. func GetGoTypeName(input any) string { var t reflect.Type + switch v := input.(type) { case reflect.Type: t = v