Skip to content

iceisfun/gojs

Repository files navigation

gojs

gojs

Go Reference

Important

gojs is pre-1.0 (v0.x) and not yet production-ready. It is tagged and go get-able, but still actively evolving: language and library coverage has gaps, and the public API surface will change (types, options, and function signatures may break between minor v0.x releases without notice).

It is suitable for experiments, prototypes, learning, and other non-serious projects — not for anything where stability or completeness matters. Pin a specific v0.x tag if you depend on it, and expect to update your code as things move toward a stable v1.

An embeddable, sandbox-first JavaScript (ECMAScript) runtime for Go. Pure Go, zero dependencies, no cgo.

gojs is a companion to GoLua: the same capability-gated, provider-based sandboxing philosophy, applied to JavaScript. Good fits include plugin systems, user scripting, configuration runtimes, automation, and running untrusted snippets under tight host control.

Status: a full lexer, parser, and AST feed a bytecode VM (with a tree-walking evaluator as the fallback and differential oracle) that runs modern JavaScript at high conformance — ~99.99% of the runnable Test262 suite (Proxy, Reflect, the whole TypedArray/ArrayBuffer family, SharedArrayBuffer + multi-agent Atomics, BigInt, WeakRef/FinalizationRegistry, generators, async/await, and a conformant RegExp engine all pass at 100% of runnable). A few areas are intentionally out of scope — see Limitations.

Features

  • Modern JS syntaxlet/const, arrow functions, classes (with extends/super, private #fields, static, getters/setters, instance fields), destructuring with defaults and rest, spread, template & tagged-template literals, optional chaining (?.), nullish coalescing (??), logical assignment (&&= ||= ??=), exponentiation, BigInt literals, computed keys, and labeled break/continue.
  • Full statement setif/else, for, for-in, for-of, while, do-while, switch, try/catch/finally (incl. optional catch binding), throw, and Automatic Semicolon Insertion.
  • Built-insObject, Function, Array, String, Number, Boolean, BigInt, Symbol (well-known symbols), Map, Set, WeakMap, WeakSet, WeakRef, FinalizationRegistry, Promise, Proxy, Reflect, the full TypedArray family + ArrayBuffer, SharedArrayBuffer, DataView, and Atomics, Math, JSON, a conformant RegExp engine, the Error hierarchy, and the global helpers (parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, …).
  • Own RegExp engine — a pure-Go, ECMAScript-conformant matcher (jsregexp) with backreferences, lookahead/lookbehind, named groups, the u/v Unicode modes, and \p{…} property escapes over Unicode 17.0 data — the things Go's RE2 deliberately cannot do — with a step budget so untrusted patterns can't hang the host. (An opt-in RE2 backend is available for linear-time matching of simple, trusted patterns.)
  • Bytecode VM — hot functions compile to a compact stack bytecode; the original tree-walking evaluator remains as an always-correct fallback (for not-yet-lowered nodes) and as a differential oracle. Both engines produce identical results; the VM is the default.
  • Generators & async/awaitfunction*, yield, yield*, async functions, await, and async arrows, all driven cooperatively on the single VM thread (an await is a yield to a promise-driven runner), so ordering matches real engines.
  • Shared memory & multi-agent AtomicsSharedArrayBuffer plus Atomics.wait/waitAsync/notify coordinate across multiple agents (each a separate interpreter on its own goroutine), so worker-style shared-memory code runs correctly.
  • Event loop & timerssetTimeout, setInterval, clearTimeout, clearInterval, setImmediate, and queueMicrotask, all serialized on a single event-loop goroutine so callbacks never race with script code.
  • Context cancellation — every evaluation threads a context.Context; Close() cancels it and drains all timer goroutines.
  • Precise diagnostics — every token and AST node carries a source span (line/column/offset) for underline-quality error messages.
  • TypeScript — run .ts/.tsx directly. The optional ts package transpiles TypeScript to JavaScript in-process (embedding a hoisting of Microsoft's typescript-go), so gojs run app.ts and embedded TypeScript just work. Type-stripping is checker-free; the gojs core stays dependency-free (only importing ts pulls the compiler in). See the typescript example.
  • No cgo, no C dependencies, single static binary.

Capability model

Like GoLua, gojs is closed by default. Access to host facilities is granted by installing providers; a New() with no options cannot print, read the clock, or schedule timers.

Provider Controls Default implementation
PrintProvider all console.* output NewDefaultPrintProvider()
TimeProvider Date / performance.now clock source NewDefaultTimeProvider()
TimerProvider setTimeout / setInterval scheduling NewDefaultTimerProvider()
ModuleProvider require(specifier) module loading NewMapModuleProvider, NewDirModuleProvider
OsProvider process env / cwd / exit / platform / arch / pid NewDefaultOsProvider(), NewFilteredOsProvider(filter)
NetProvider outbound dialing/DNS for fetch/sse/websocket NewDefaultNetProvider() (pass-through; wrap to allowlist/deny)

Resource use is bounded with WithLimits(Limits{MaxCallDepth, MaxSteps}): recursion raises a catchable RangeError, and the step budget is an uncatchable abort that stops runaway loops. See the limits example.

Each provider is a small Go interface, so you can supply your own — route console.* through your logger, present a fixed clock for deterministic tests, or back timers with your own scheduler:

type PrintProvider interface {
	Print(ctx context.Context, msg string) // console.log/info/debug
	Warn(ctx context.Context, msg string)  // console.warn/error
}

Additional hardening is available through WithSecurity(Security{…}): DisableEval, DisableFunctionCtor, DisableProtoMutation, StrictModulesOnly.

Host async integration

Concurrent host work (HTTP, filesystem, DB, subprocess) integrates through a single-threaded event-loop model — the interpreter is never touched from more than one goroutine. A provider does its blocking work on its own goroutine, then posts exactly one continuation back onto the VM goroutine:

cap := vm.NewPromiseCapability()   // create a pending promise on the VM goroutine
go func() {
	body, err := http.Get(url)     // blocking work off the VM goroutine
	if err != nil {
		cap.Reject(vm.NewError("Error", err.Error())) // marshals back to the loop
		return
	}
	cap.Resolve(gojs.String(body))
}()
return cap.Promise                 // hand the promise to the script

Enqueue, QueueMicrotask, ResolvePromise/RejectPromise, and RunLoop round out the API. All JavaScript — the initial program and every callback — runs on one goroutine, so objects, environments, and closures never need locks.

Installation

go get github.com/iceisfun/gojs
# CLI
go install github.com/iceisfun/gojs/cmd/gojs@latest

Quick start

package main

import (
	"log"

	"github.com/iceisfun/gojs"
)

func main() {
	vm := gojs.New(
		gojs.WithPrintProvider(gojs.NewDefaultPrintProvider()),
		gojs.WithTimerProvider(gojs.NewDefaultTimerProvider()),
	)
	defer vm.Close()

	_, err := vm.RunString("example.js", `
		const nums = [1, 2, 3, 4];
		const evens = nums.filter(n => n % 2 === 0);
		console.log("sum of evens:", evens.reduce((a, b) => a + b, 0));
		setTimeout(() => console.log("done"), 10);
	`)
	if err != nil {
		log.Fatal(err)
	}
}

The embedding flow is gojs.NewRunStringClose. RunString parses, evaluates the top level, then drains the event loop (so pending timers and promise microtasks run) before returning.

CLI

go install github.com/iceisfun/gojs/cmd/gojs@latest

gojs run app.js               # run a JavaScript file
gojs run app.ts               # TypeScript, transpiled in-process
gojs run --permissive app.ts  # tolerate TypeScript syntax errors

Malformed TypeScript is rejected with a file:line:column error by default (safer when the output is about to run); --permissive (or ts.Permissive() when embedding) restores ts.transpileModule's tolerant behavior. Runtime error stacks report the original .ts position via source maps.

The runner resolves require()/imports against the entry file's directory (TypeScript modules are transpiled on load) and installs the standalone-runner capabilities: console, timers, and a Node-like process (argv, env, stdout.write, exit, hrtime, nextTick). These are ordinary host capabilities — process is the host/process package, which an embedder installs (and sandboxes) explicitly per VM.

Go interop

Move values and control across the Go/JavaScript boundary with a small API.

Expose a Go function to scripts:

vm.SetGlobal("shout", vm.NewFunction("shout", func(args []gojs.Value) (gojs.Value, error) {
	s, _ := vm.ToString(args[0])
	return gojs.String(strings.ToUpper(s)), nil
}))
vm.RunString("s.js", `console.log(shout("hi"))`) // HI

Returning an error from a HostFunc throws it into the script; wrap a value with gojs.NewThrow(...) to throw a specific JS object.

Call a script function from Go:

vm.RunString("lib.js", `function add(a, b) { return a + b; }`)
sum, _ := vm.Call(vm.GetGlobal("add"), gojs.Undefined, gojs.Number(2), gojs.Number(3))
fmt.Println(vm.ToGo(sum)) // 5

Convert values both ways with vm.FromGo (Go → JS: scalars, []any, map[string]any) and vm.ToGo / vm.ToString (JS → Go).

Security model

gojs is designed to run untrusted JavaScript. The posture is defense in depth, and every layer is off by default:

  • No ambient authority. A bare gojs.New() has no way to print, read the clock, schedule timers, or load modules. Scripts see only pure-computation built-ins. Capabilities are granted one provider at a time, and each provider is an interface you can implement to mediate, log, or refuse individual operations.
  • No dynamic code by default. eval and the Function constructor are gated by Security{DisableEval, DisableFunctionCtor}; the CLI --sandbox mode turns them off entirely.
  • Prototype-pollution guard. DisableProtoMutation freezes mutation of built-in prototypes so untrusted code can't tamper with the shared realm.
  • Bounded resources. WithLimits(Limits{MaxCallDepth, MaxSteps}) caps recursion (catchable RangeError) and total evaluation steps (an uncatchable abort), and every evaluation honors a context.Context deadline. A hostile while (true) {} is stopped by the step budget or the context, not by luck.
  • Single-threaded by construction. JavaScript never runs on two goroutines at once, so a script cannot exploit host-side data races. Close() cancels the context and drains outstanding timers and coroutines.
vm := gojs.New(
	gojs.WithSecurity(gojs.Security{
		DisableEval:          true,
		DisableFunctionCtor:  true,
		DisableProtoMutation: true,
	}),
	gojs.WithLimits(gojs.Limits{MaxCallDepth: 512, MaxSteps: 5_000_000}),
)

See the sandbox and limits examples for end-to-end setups.

Standard library

Implemented intrinsics and globals (see Limitations for gaps):

Area Coverage
Object literals, descriptors, keys/values/entries, assign, freeze/seal, create, getPrototypeOf, is, groupBy
Array literals, iteration (map/filter/reduce/…), sort, flat, ES2023 toSorted/toReversed/toSpliced/with, from/of, Array.isArray
String full method set incl. match/matchAll/replace/replaceAll/split (regex-aware), templates, padStart/padEnd
Number/Math numeric methods, toFixed/toPrecision/toString(radix), the Math.* surface
JSON parse/stringify with replacer (function + array), reviver, toJSON, cycle detection
RegExp own ECMAScript engine: exec/test/Symbol.{match,replace,split,…}, all flags, backreferences, lookahead/lookbehind, named groups, u/v modes, \p{…} (jsregexp); opt-in RE2 backend
BigInt arbitrary-precision integers, literals, asIntN/asUintN, mixed-type guards
Binary data full TypedArray family, ArrayBuffer (resizable), SharedArrayBuffer (growable), DataView, Atomics (incl. multi-agent wait/waitAsync/notify)
Proxy/Reflect all traps and the corresponding Reflect.* operations, realm-aware
Collections Map, Set, WeakMap, WeakSet, WeakRef, FinalizationRegistry, and Promise (with the microtask queue)
Symbol well-known symbols (iterator, asyncIterator, hasInstance, …)
Errors full Error hierarchy (incl. AggregateError), subclassable
Globals parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, Date, Promise, timers

TypeScript

gojs can run TypeScript with no external tooling. The ts package transpiles .ts/.tsx to JavaScript in-process — it embeds github.com/iceisfun/typescript, a hoisting of Microsoft's typescript-go compiler out of its internal/ packages — and runs the result on the VM. Importing ts is what pulls that dependency into a build; embeddings that only run JavaScript keep the zero-dependency core.

import "github.com/iceisfun/gojs/ts"

// Self-contained script:
vm := gojs.New(gojs.WithPrintProvider(gojs.NewDefaultPrintProvider()))
ts.RunString(vm, "app.ts", `const n: number = 21; console.log(n * 2);`) // 42

// Multi-file: ts.Provider wraps any ModuleProvider so .ts modules are
// transpiled on load; import/require between them resolve through it.
vm = gojs.New(
    gojs.WithPrintProvider(gojs.NewDefaultPrintProvider()),
    gojs.WithModuleProvider(ts.Provider(gojs.NewDirModuleProvider("./src"))),
)
vm.RunString("<entry>", `require("./main.ts")`)

From the CLI:

gojs run app.ts        # transpile + run a TypeScript entry file

Transpilation is checker-free (the isolatedModules model): type annotations, interfaces, generics, and class visibility are erased/lowered, and enum/const enum/namespace are lowered to runnable JavaScript — but the program is not type-checked (the goal is to run TypeScript). Runtime error stacks are source-mapped back to the original .ts line/column. Frontend-only features that need a code transform — JSX and decorators — are out of scope (gojs runs TypeScript as a scripting language, not a build tool) and are rejected up front with a clear error rather than mis-compiled. See the typescript example.

Examples

Runnable programs live under examples/, each with its own README:

Example Shows
basic Run a script and read back its result
expose_go Expose Go functions and data to JavaScript
call_js Call script functions from Go
providers Custom PrintProvider + timers on the event loop
modules Intercept require() with a ModuleProvider
typescript Run TypeScript (transpiled in-process) with imports
regexp_engine Choose the RegExp backend (conformant vs RE2)
limits Bound recursion and CPU with Limits
sandbox Hardened sandbox: no I/O, no eval, timeout on runaway
fetch The fetch API over a NetProvider (host/fetch)
websocket A browser-compatible WebSocket client (host/websocket)
sse Server-Sent Events / EventSource (host/sse)
go run ./examples/basic
go run ./examples/expose_go
go run ./examples/providers

Architecture

gojs is organized into layered packages with no import cycles:

Source → lexer → parser → ast → interp ──► bytecode compiler → VM   (default)
                                    │
                                    └────► tree-walking evaluator    (fallback + oracle)
                                    ↑
                                Providers
Package Purpose
token Lexical token types, categories, keywords, source spans
lexer Tokenizes source (regex/division disambiguation, templates, ASI)
ast AST node definitions (Expr/Stmt) with positions
parser Recursive-descent + precedence-climbing parser
interp The runtime: values, objects, environments, the bytecode compiler + VM, the tree-walking evaluator, built-ins, providers, event loop
jsregexp Standalone ECMAScript RegExp engine (embeddable on its own)

The root package gojs re-exports the common surface of interp. The bytecode VM lowers hot function bodies to a stack machine and falls back to the tree-walker per-subtree for not-yet-lowered constructs, so a compiled body is always correct; the two engines are validated to produce identical results across the whole suite.

Project structure

gojs/
├── doc.go            package overview (pkg.go.dev landing)
├── gojs.go           root package: re-exported public surface
├── token/            token kinds, source positions, spans
├── lexer/            lexical scanner
├── ast/              AST node types
├── parser/           recursive-descent parser
├── interp/           evaluator, values, built-ins, providers, event loop, host API
├── ts/               optional TypeScript support (transpile + run; pulls in typescript-go)
├── cmd/gojs/         command-line runner (runs .js and .ts)
├── examples/         runnable embedding examples (each with a README)
└── tests/
    ├── harness/      behavioral JS conformance suite (self-asserting programs)
    ├── doctest/      documentation examples run as tests
    └── test262/      optional Test262 runner (gated behind GOJS_T262)

Running tests

go test ./...              # full suite (fast; Test262 is skipped)
go test ./... -race        # race detector across the event loop and coroutines
go test ./tests/harness    # just the JS behavioral suite

The behavioral suite runs real JavaScript programs that assert their own results, so a regression surfaces as a failing Go test with the thrown value. The optional Test262 runner is gated behind the GOJS_T262 environment variable (pointing at a local checkout) so the default go test ./... stays fast and hermetic.

Contributing

The workflow is find-fix-test: when you hit a behavior that diverges from the spec, first reproduce it as a failing test in tests/harness, then fix the engine until it passes. Keep go test ./... -race green. Intentional divergences from the spec (chiefly the code-point-indexed string model) are recorded in NOTES-divergences.md so they aren't mistaken for bugs. New built-ins and language features should land with harness coverage.

Limitations

Coverage is high (~99.99% of runnable Test262), but some areas are deliberately out of scope or approximated:

  • Strings are indexed by Unicode code point, not UTF-16 code unit. This is a deliberate divergence: .length, charCodeAt, and friends count code points, and astral characters/lone surrogates don't round-trip through UTF-16-observing APIs the way a browser engine's do. See NOTES-divergences.md.
  • ES modules — CommonJS require() works through a ModuleProvider (see the modules and typescript examples), and dynamic import() resolves against a module provider. Native top-level ES import/export module linking is not run directly; TypeScript's ES modules run by transpiling to CommonJS.
  • eval / Function(...) dynamic code is supported but gated off by default under the sandbox security posture (DisableEval / DisableFunctionCtor).
  • Not implemented (out of scope for now): Intl, Temporal, the explicit-resource-management proposal (DisposableStack/using), and a small set of other stage-track proposals. Tests for these are skipped, not failed.
  • Atomics.wait on a non-blocking agent ([[CanBlock]] = false) is not modeled — a gojs agent always can block.

Conformance

gojs is measured against the official Test262 suite. Current standing: ~99.99% of runnable tests pass, with entire domains at 100% of runnable — including Proxy, Reflect, the TypedArray/ArrayBuffer family, SharedArrayBuffer + multi-agent Atomics, RegExp, BigInt, WeakRef, and FinalizationRegistry. The handful of remaining failures are deferred non-bugs (a legacy Annex-B .caller misfeature and one upstream-broken proposal test); the large skip buckets are unimplemented proposals (Temporal, etc.), not failures. The bytecode VM and the tree-walker are cross-checked to produce identical results across the whole suite.

License

See LICENSE.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages