moejs README

The project's README from github.com/Calcium-Ion/moejs. As one markdown file: README.md.

moejs

moejs

A JavaScript runtime in pure Go, for running JavaScript plugins inside Go programs.

简体中文 | English

moejs supports modern JavaScript, including ES modules, classes, async/await, Proxy and BigInt, and passes all 79,385 test262 tests it runs.

On the same plugins as Sobek, another pure-Go engine, a moejs call takes about half the time and a runtime uses about a third of the memory, so a server can give every concurrent request its own runtime.

Performance

The workload is new-api's 10 task plugins and 269 recorded calls. Times are taken on the Go caller's side and include converting the arguments and the result.

moejs Sobek QuickJS (quickjs-go) V8 (v8go)
One plugin call 6.9 µs 14.3 µs 104.5 µs 56.6 µs ¹
New runtime 1.4 µs 2.2 µs 382 µs 1,153 µs ¹
Memory per runtime with the largest plugin loaded 81 KiB 264 KiB 348 KiB ² 1,544 KiB ²

¹ V8's timings varied widely on the test machine. ² The engine's own heap.

Sobek is a pure-Go engine like moejs. QuickJS and V8 run through cgo. The test machine, the full results and how to reproduce them are in docs/performance.md.

Features

Pure Go

moejs builds with CGO_ENABLED=0 and cross-compiles without a C toolchain. The engine is ordinary Go code, so pprof and the race detector see inside it.

Passing values

Plugin functions take Go maps and slices or JSON. moejs converts a map one level at a time, as far as the plugin reads it. Results come back as Go values or JSON, or go straight into your own struct with the same result as json.Unmarshal. Host functions are plain Go functions, and one can return a promise and settle it later from Go.

What a plugin can reach

A plugin sees the JavaScript standard library and the globals you install. Every import and import() goes through a Go function you provide, and eval and new Function can be capped in length or turned off. Builtins are frozen and shared by every runtime, so every plugin sees the same Array.prototype. Each runtime has its own globals.

Timeouts and errors

Any goroutine can interrupt a running plugin, even one stuck in an endless loop. A JavaScript throw, a syntax error, an interrupt and a panic in a host function each come back as their own Go error type, and a thrown Error has a V8-style stack trace. A runtime stays usable after a host function panics.

Quick start

go get github.com/Calcium-Ion/moejs

moejs needs Go 1.25 or later and depends only on the standard library (tests use testify).

package main

import (
	"context"
	"errors"
	"fmt"
	"runtime"
	"sync"
	"time"

	"github.com/Calcium-Ion/moejs"
)

const source = `
export function buildRequest(task) {
  return {
    method: "POST",
    url: "https://api.example.com/v1/tasks",
    headers: { authorization: "Bearer " + utils.env("API_KEY") },
    body: { prompt: task.prompt.trim(), n: task.n ?? 1 },
  };
}
export function spin() { for (;;) {} }
`

// Request is what buildRequest returns.
type Request struct {
	Method  string            `json:"method"`
	URL     string            `json:"url"`
	Headers map[string]string `json:"headers"`
	Body    struct {
		Prompt string `json:"prompt"`
		N      int    `json:"n"`
	} `json:"body"`
}

// Plugin is a compiled plugin and a pool of runtimes that have loaded it.
type Plugin struct {
	mod  *moejs.Module
	idle chan *moejs.Runtime
}

func NewPlugin(name, source string, size int) (*Plugin, error) {
	// Compile once. Every runtime in the pool loads the same Module.
	mod, err := moejs.Compile(name, source)
	if err != nil {
		return nil, err
	}
	return &Plugin{mod: mod, idle: make(chan *moejs.Runtime, size)}, nil
}

// Call runs hook on a runtime from the pool and decodes the result into out.
// Any number of goroutines can call it at once.
func (p *Plugin) Call(ctx context.Context, hook moejs.Hook, args map[string]any, out any) error {
	rt, err := p.get()
	if err != nil {
		return err
	}
	defer p.put(rt)

	// Interrupt stops the hook when ctx ends. Any goroutine can call it.
	interrupted := make(chan struct{})
	stop := context.AfterFunc(ctx, func() {
		rt.Interrupt(context.Cause(ctx))
		close(interrupted)
	})
	defer func() {
		if !stop() {
			<-interrupted // Interrupt must return before rt goes back to the pool.
		}
	}()

	// FromGo converts the map as the plugin reads it, one level at a time.
	arg, err := rt.FromGo(args)
	if err != nil {
		return err
	}
	res, err := rt.Call(hook, arg)
	if err != nil {
		return err
	}
	// Unmarshal writes the result straight into out, with no JSON text in between.
	return rt.Unmarshal(res, out)
}

// get takes an idle runtime or makes a new one: host functions first, then the module.
func (p *Plugin) get() (*moejs.Runtime, error) {
	select {
	case rt := <-p.idle:
		return rt, nil
	default:
	}
	rt := moejs.NewRuntime(moejs.Options{})
	if err := rt.SetGlobal("utils", map[string]any{"env": moejs.NativeFunc(env)}); err != nil {
		return nil, err
	}
	if err := rt.Load(p.mod); err != nil {
		return nil, err
	}
	return rt, nil
}

// put resets the runtime and returns it to the pool.
func (p *Plugin) put(rt *moejs.Runtime) {
	rt.ClearInterrupt()
	rt.ReleaseCallData() // The idle runtime lets go of this call's arguments.
	select {
	case p.idle <- rt:
	default: // The pool is full.
	}
}

var secrets = map[string]string{"API_KEY": "test-key"}

// env is the host function behind utils.env.
func env(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
	name, err := r.ToString(moejs.Arg(args, 0))
	if err != nil {
		return moejs.Undefined(), err
	}
	v, ok := secrets[name.GoString()]
	if !ok {
		// A Go error becomes a JavaScript Error with this message.
		return moejs.Undefined(), fmt.Errorf("%s is not set", name.GoString())
	}
	return moejs.String(v), nil
}

func main() {
	p, err := NewPlugin("plugin.js", source, runtime.GOMAXPROCS(0))
	if err != nil {
		panic(err)
	}
	// Look hooks up once and reuse them on every call.
	build, err := p.mod.Hook("buildRequest")
	if err != nil {
		panic(err)
	}
	spin, err := p.mod.Hook("spin")
	if err != nil {
		panic(err)
	}

	// Concurrent calls each get their own runtime.
	reqs := make([]Request, 3)
	var wg sync.WaitGroup
	for i := range reqs {
		wg.Go(func() {
			task := map[string]any{"prompt": fmt.Sprintf(" cat %d ", i), "n": i + 1}
			if err := p.Call(context.Background(), build, task, &reqs[i]); err != nil {
				panic(err)
			}
		})
	}
	wg.Wait()
	for _, req := range reqs {
		fmt.Println(req.Method, req.URL, req.Headers["authorization"], req.Body.Prompt, req.Body.N)
	}
	// POST https://api.example.com/v1/tasks Bearer test-key cat 0 1
	// POST https://api.example.com/v1/tasks Bearer test-key cat 1 2
	// POST https://api.example.com/v1/tasks Bearer test-key cat 2 3

	// A JavaScript throw comes back as *moejs.Exception.
	err = p.Call(context.Background(), build, map[string]any{}, &Request{})
	var exc *moejs.Exception
	fmt.Println(errors.As(err, &exc), exc.Name(), exc.Message())
	// true TypeError Cannot read properties of undefined (reading 'trim')

	// A hook still running when ctx ends stops with *moejs.InterruptedError.
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()
	err = p.Call(ctx, spin, nil, nil)
	var interrupted *moejs.InterruptedError
	fmt.Println(errors.As(err, &interrupted), interrupted.Value)
	// true context deadline exceeded
}

A server calling plugins the way Plugin does pays the least per request. Taking a runtime from the pool is one channel receive, while a new runtime with the largest plugin loaded takes about 71 µs. The guide covers pools, module graphs, value conversion, promises and errors, and the package documentation describes every function.

moejs ships a default.pgo profile recorded from the plugin workload. Go applies a profile automatically only from the main package's directory, so pass it when you build:

go build -pgo="$(go list -m -f '{{.Dir}}' github.com/Calcium-Ion/moejs)/default.pgo" .

JavaScript support

moejs runs ES modules and classic scripts, including sloppy mode and the web-compatibility behaviour of Annex B. The language includes classes with fields, private members and static blocks, destructuring, optional chaining, generators, async functions and async iteration. The standard library has Proxy, Reflect, BigInt, typed arrays, resizable ArrayBuffers, WeakRef, structuredClone, TextEncoder/TextDecoder, and recent additions such as the Set methods, Promise.try, Float16Array, Array.fromAsync, Math.sumPrecise and Error.isError. Regular expressions support every flag and Unicode 17 properties, and Date takes its time zones from Go.

On test262, 79,385 tests pass and 0 fail. The other 14,058 tests use features moejs does not implement and are skipped. Results by directory are in bench/test262/RESULTS.md.

Not implemented: import attributes and JSON modules, using declarations and DisposableStack, decorators, iterator helpers, Intl, Temporal, ShadowRealm, FinalizationRegistry, timers and JSON.rawJSON. TODO.md lists all of them, the known wrong results and the limits.

Status

moejs is in alpha, and the API may change between releases.

Testing

# Test inputs are downloaded separately: new-api's plugins and a pinned
# test262 revision. Tests that need them skip until they are downloaded.
bench/testdata/plugins/fetch.sh
bench/test262/fetch.sh

# Unit, audit and fuzz-corpus tests of the engine.
go test ./...

# Differential tests against Sobek, the expression corpus, the benchmarks and
# test262. bench/ is a separate Go module, so Sobek and the cgo engines are
# dependencies of bench/ only. Its V8 and QuickJS baselines need cgo.
cd bench && go test -timeout 30m ./...

Acknowledgements

moejs takes design ideas from these projects. Its code was written independently.

License

moejs is licensed under the Apache License 2.0.

The benchmarks run new-api's task plugins, which are licensed under AGPL-3.0 and downloaded separately by bench/testdata/plugins/fetch.sh.

moejs (Calcium-Ion/moejs)

Searching Database…

287 ·GitHub
这是什么?moejs 是一个完全用 Go 编写的 JavaScript 引擎,用于在 Go 宿主程序中安全、快速地运行小型 JavaScript 插件。
它是独立应用,还是嵌入我 Go 程序里的库?它是一个 Go 库(可嵌入的 JS 引擎),不是独立的运行时或命令行工具——你需要导入它,并从自己的 Go 代码启动运行时。
怎么把它加进 Go 项目?把 moejs 添加为模块依赖,并从一个池中创建运行时,方式类似 new-api 调用其任务插件;从池里取一个运行时的开销大约相当于一次 channel 接收。
我需要先装 Go 还是 C 工具链?需要 Go 1.25 或更高版本;moejs 只依赖标准库,并能在 CGO_ENABLED=0 下构建,因此不需要 C 工具链(测试使用 testify)。
它的 JavaScript 支持完整吗?不完整——它还是 alpha 软件,API 可能变化;装饰器、Intl、Temporal、ShadowRealm、FinalizationRegistry、定时器和导入属性等一些较新或冷门的特性尚未实现。
什么样的人真正需要这种东西?需要在每个请求中运行大量短生命周期、相互隔离的插件脚本的 Go 开发者——例如提供按请求的插件逻辑,让每个请求都拿到一个用完即弃的 JS 运行时。
moejs 比其他引擎快多少?在相同插件上与 Sobek(另一个纯 Go 引擎)对比,moejs 一次调用的耗时约为一半(6.9 μs 对 14.3 μs),一个运行时的内存占用大约只有三分之一(81 KiB 对 264 KiB);它还大幅领先基于 cgo 的 QuickJS 和 V8。
这个引擎的标准兼容性如何?moejs 通过了它运行的全部 79,385 个 test262 测试;被跳过的 14,058 个测试针对的是它尚未实现的特性,清单见 TODO.md。
插件能逃逸出去影响其他运行时吗?不能——Array.prototype 等内置对象被冻结并共享,但每个运行时都有自己的全局对象,而且每一次 import、eval 和 new Function 调用都会经过一个由 Go 控制的钩子,你可以对它设上限或禁用。
如果插件卡死或宿主函数发生 panic 会怎样?任何 goroutine 都可以中断卡住的插件,宿主函数中的 panic、抛出的异常、语法错误和中断各自以不同的 Go 错误类型呈现,不会永久性损坏运行时。
What are similar projects?monty, badger, tokio, sdk-rust, mquickjs, conc, compio, go
Size
5.1 MB
Stars
287
Updated
CommitOct 14
Age
23d
Commits · 90 Days